| "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(), |
| }); |
|
|
| |
| 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> |
| ); |
| } |
|
|