Midday / apps /dashboard /src /components /forms /tracker-project-form.tsx
Jules
Final deployment with all fixes and verified content
c09f67c
"use client";
import type { RouterOutputs } from "@api/trpc/routers/_app";
import { uniqueCurrencies } from "@midday/location/currencies";
import { Collapsible, CollapsibleContent } from "@midday/ui/collapsible";
import { CurrencyInput } from "@midday/ui/currency-input";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@midday/ui/form";
import { Input } from "@midday/ui/input";
import { Label } from "@midday/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@midday/ui/select";
import { SubmitButton } from "@midday/ui/submit-button";
import { Switch } from "@midday/ui/switch";
import { Textarea } from "@midday/ui/textarea";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { z } from "zod/v3";
import { SearchCustomers } from "@/components/search-customers";
import { SelectTags } from "@/components/select-tags";
import { useCustomerParams } from "@/hooks/use-customer-params";
import { useLatestProjectId } from "@/hooks/use-latest-project-id";
import { useTrackerParams } from "@/hooks/use-tracker-params";
import { useUserQuery } from "@/hooks/use-user";
import { useZodForm } from "@/hooks/use-zod-form";
import { useTRPC } from "@/trpc/client";
const formSchema = z.object({
id: z.string().uuid().optional(),
name: z.string().min(1),
description: z.string().optional(),
estimate: z.number().optional(),
billable: z.boolean().optional().default(false),
rate: z.number().min(1).optional(),
currency: z.string().optional(),
status: z.enum(["in_progress", "completed"]).optional(),
customerId: z.string().uuid().nullable().optional(),
tags: z
.array(
z.object({
id: z.string().uuid(),
value: z.string(),
}),
)
.optional(),
});
type Props = {
data?: RouterOutputs["trackerProjects"]["getById"];
defaultCurrency: string;
};
export function TrackerProjectForm({ data, defaultCurrency }: Props) {
const isEdit = !!data;
const trpc = useTRPC();
const queryClient = useQueryClient();
const { data: user } = useUserQuery();
const { setParams: setTrackerParams } = useTrackerParams();
const { setParams: setCustomerParams } = useCustomerParams();
const { setLatestProjectId } = useLatestProjectId(user?.teamId);
const upsertTrackerProjectMutation = useMutation(
trpc.trackerProjects.upsert.mutationOptions({
onSuccess: (result) => {
setLatestProjectId(result?.id ?? null);
queryClient.invalidateQueries({
queryKey: trpc.trackerProjects.get.infiniteQueryKey(),
});
queryClient.invalidateQueries({
queryKey: trpc.trackerProjects.getById.queryKey(),
});
// Close the tracker project form
setTrackerParams({ create: null });
},
}),
);
const form = useZodForm(formSchema, {
defaultValues: {
id: data?.id,
name: data?.name ?? undefined,
description: data?.description ?? undefined,
rate: data?.rate ?? undefined,
status: data?.status ?? "in_progress",
billable: data?.billable ?? false,
estimate: data?.estimate ?? 0,
currency: data?.currency ?? defaultCurrency,
customerId: data?.customerId ?? undefined,
tags:
data?.tags?.map((tag) => ({
id: tag.id ?? "",
value: tag.name ?? "",
})) ?? undefined,
},
});
const onSubmit = (data: z.infer<typeof formSchema>) => {
const formattedData = {
...data,
id: data.id || undefined,
description: data.description || null,
rate: data.rate || null,
currency: data.currency || null,
billable: data.billable || false,
estimate: data.estimate || null,
status: data.status || "in_progress",
customerId: data.customerId || null,
tags: data.tags?.length ? data.tags : null,
};
upsertTrackerProjectMutation.mutate(formattedData);
};
useEffect(() => {
if (data) {
form.reset({
id: data?.id,
name: data?.name ?? undefined,
description: data?.description ?? undefined,
rate: data?.rate ?? undefined,
status: data?.status ?? "in_progress",
billable: data?.billable ?? false,
estimate: data?.estimate ?? 0,
currency: data?.currency ?? defaultCurrency,
customerId: data?.customerId ?? undefined,
tags:
data?.tags?.map((tag) => ({
id: tag.id ?? "",
value: tag.name ?? "",
})) ?? undefined,
});
}
}, [data]);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
autoComplete="off"
placeholder="Project name"
autoCapitalize="none"
autoCorrect="off"
spellCheck="false"
autoFocus
/>
</FormControl>
<FormDescription>
This is the project display name.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="customerId"
render={({ field }) => (
<FormItem>
<FormLabel>Customer</FormLabel>
<FormControl>
<SearchCustomers
onSelect={(id) =>
field.onChange(id, {
shouldDirty: true,
shouldValidate: true,
})
}
selectedId={field.value ?? undefined}
onCreate={(name) => {
setCustomerParams({
name,
createCustomer: true,
});
}}
onEdit={(id) => {
setCustomerParams({
customerId: id,
});
}}
/>
</FormControl>
<FormDescription>
Link a customer to enable direct invoicing.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-6">
<Label htmlFor="tags" className="mb-2 block">
Expense Tags
</Label>
<SelectTags
tags={(form.getValues("tags") ?? []).map((tag) => ({
id: tag.id,
value: tag.value,
label: tag.value,
}))}
onRemove={(tag) => {
form.setValue(
"tags",
form.getValues("tags")?.filter((t) => t.id !== tag.id),
{
shouldDirty: true,
shouldValidate: true,
},
);
}}
onSelect={(tag) => {
form.setValue(
"tags",
[
...(form.getValues("tags") ?? []),
{
value: tag.value ?? "",
id: tag.id ?? "",
},
],
{
shouldDirty: true,
shouldValidate: true,
},
);
}}
/>
<FormDescription className="mt-2">
Tags help categorize and track project expenses.
</FormDescription>
</div>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea className="resize-none" {...field} />
</FormControl>
<FormDescription>
Add a short description about the project.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex space-x-4 mt-4">
<FormField
control={form.control}
name="estimate"
render={({ field }) => (
<FormItem>
<FormLabel>Time Estimate</FormLabel>
<FormControl>
<Input
placeholder="0"
{...field}
type="number"
min={0}
onChange={(evt) => field.onChange(+evt.target.value)}
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
spellCheck="false"
/>
</FormControl>
<FormDescription>
Set a goal for how long your project should take to complete
in hours.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Status</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<Collapsible open={form.watch("billable")}>
<FormItem className="flex justify-between items-center">
<FormLabel>Billable</FormLabel>
<FormField
control={form.control}
name="billable"
render={({ field }) => (
<FormItem>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</FormItem>
<CollapsibleContent className="space-y-2 w-full">
<div className="flex space-x-4 mt-4">
<FormField
control={form.control}
name="rate"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Hourly Rate</FormLabel>
<FormControl>
<CurrencyInput
min={0}
value={field.value}
onValueChange={(values) => {
field.onChange(values.floatValue);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="currency"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Currency</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent className="max-h-[300px]">
{uniqueCurrencies.map((currency) => (
<SelectItem value={currency} key={currency}>
{currency}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</Collapsible>
<div className="fixed bottom-8 w-full sm:max-w-[455px] right-8">
<SubmitButton
className="w-full"
disabled={
upsertTrackerProjectMutation.isPending || !form.formState.isDirty
}
isSubmitting={upsertTrackerProjectMutation.isPending}
>
{isEdit ? "Update" : "Create"}
</SubmitButton>
</div>
</form>
</Form>
);
}