| "use client"; |
|
|
| import { uniqueCurrencies } from "@midday/location/currencies"; |
| import { AnimatedSizeContainer } from "@midday/ui/animated-size-container"; |
| import { |
| Dialog, |
| DialogContent, |
| DialogDescription, |
| DialogHeader, |
| DialogTitle, |
| } from "@midday/ui/dialog"; |
| import { Icons } from "@midday/ui/icons"; |
| import { SubmitButton } from "@midday/ui/submit-button"; |
| import { useToast } from "@midday/ui/use-toast"; |
| import { stripSpecialCharacters } from "@midday/utils"; |
| import { useMutation, useQueryClient } from "@tanstack/react-query"; |
| import { parseAsBoolean, parseAsString, useQueryStates } from "nuqs"; |
| import { useEffect, useState } from "react"; |
| import { useInvalidateTransactionQueries } from "@/hooks/use-invalidate-transaction-queries"; |
| import { useJobStatus } from "@/hooks/use-job-status"; |
| import { useTeamQuery } from "@/hooks/use-team"; |
| import { useUpload } from "@/hooks/use-upload"; |
| import { useUserQuery } from "@/hooks/use-user"; |
| import { useZodForm } from "@/hooks/use-zod-form"; |
| import { useTRPC } from "@/trpc/client"; |
| import { ImportCsvContext, importSchema } from "./context"; |
| import { FieldMapping } from "./field-mapping"; |
| import { SelectFile } from "./select-file"; |
|
|
| const pages = ["select-file", "confirm-import"] as const; |
|
|
| export function ImportModal() { |
| const { data: team } = useTeamQuery(); |
| const defaultCurrency = team?.baseCurrency || "USD"; |
| const trpc = useTRPC(); |
| const queryClient = useQueryClient(); |
| const invalidateTransactionQueries = useInvalidateTransactionQueries(); |
| const [jobId, setJobId] = useState<string | undefined>(); |
| const [isImporting, setIsImporting] = useState(false); |
| const [fileColumns, setFileColumns] = useState<string[] | null>(null); |
| const [firstRows, setFirstRows] = useState<Record<string, string>[] | null>( |
| null, |
| ); |
|
|
| const { data: user } = useUserQuery(); |
|
|
| const [pageNumber, setPageNumber] = useState<number>(0); |
| const page = pages[pageNumber]; |
|
|
| const { uploadFile } = useUpload(); |
|
|
| const { toast } = useToast(); |
|
|
| const [params, setParams] = useQueryStates({ |
| step: parseAsString, |
| accountId: parseAsString, |
| type: parseAsString, |
| hide: parseAsBoolean.withDefault(false), |
| }); |
|
|
| const isOpen = params.step === "import"; |
|
|
| const { status } = useJobStatus({ |
| jobId, |
| enabled: !!jobId && isOpen, |
| }); |
|
|
| const importTransactions = useMutation( |
| trpc.transactions.import.mutationOptions({ |
| onSuccess: (data) => { |
| if (data?.id) { |
| setJobId(data.id); |
| } else { |
| setIsImporting(false); |
| toast({ |
| duration: 3500, |
| variant: "error", |
| title: "Something went wrong please try again.", |
| }); |
| } |
| }, |
| onError: () => { |
| setIsImporting(false); |
| setJobId(undefined); |
|
|
| toast({ |
| duration: 3500, |
| variant: "error", |
| title: "Something went wrong please try again.", |
| }); |
| }, |
| }), |
| ); |
|
|
| const { |
| control, |
| watch, |
| setValue, |
| handleSubmit, |
| reset, |
| formState: { isValid }, |
| } = useZodForm(importSchema, { |
| defaultValues: { |
| currency: defaultCurrency, |
| bank_account_id: params.accountId ?? undefined, |
| inverted: params.type === "credit", |
| }, |
| }); |
|
|
| const file = watch("file"); |
|
|
| const onclose = () => { |
| setIsImporting(false); |
| setFileColumns(null); |
| setFirstRows(null); |
| setPageNumber(0); |
| setJobId(undefined); |
| reset(); |
|
|
| setParams({ |
| step: null, |
| accountId: null, |
| type: null, |
| hide: null, |
| }); |
| }; |
|
|
| useEffect(() => { |
| if (params.accountId) { |
| setValue("bank_account_id", params.accountId); |
| } |
| }, [params.accountId]); |
|
|
| useEffect(() => { |
| if (params.type) { |
| setValue("inverted", params.type === "credit"); |
| } |
| }, [params.type]); |
|
|
| useEffect(() => { |
| if (status === "failed") { |
| setIsImporting(false); |
| setJobId(undefined); |
|
|
| toast({ |
| duration: 3500, |
| variant: "error", |
| title: "Something went wrong please try again or contact support.", |
| }); |
| } |
| }, [status, toast]); |
|
|
| useEffect(() => { |
| if (status === "completed") { |
| setIsImporting(false); |
| setJobId(undefined); |
|
|
| |
| invalidateTransactionQueries(); |
|
|
| |
| queryClient.invalidateQueries({ |
| queryKey: trpc.bankAccounts.get.queryKey(), |
| }); |
|
|
| queryClient.invalidateQueries({ |
| queryKey: trpc.bankConnections.get.queryKey(), |
| }); |
|
|
| toast({ |
| duration: 3500, |
| variant: "success", |
| title: "Transactions imported successfully.", |
| }); |
|
|
| onclose(); |
| } |
| }, [status]); |
|
|
| |
| useEffect(() => { |
| if (file && fileColumns && firstRows && pageNumber === 0) { |
| setPageNumber(1); |
| } |
| }, [file, fileColumns, firstRows, pageNumber]); |
|
|
| return ( |
| <Dialog open={isOpen} onOpenChange={onclose}> |
| <DialogContent className="overflow-visible"> |
| <div className="p-4 pb-0 max-h-[calc(100svh-10vw)] overflow-y-auto overflow-x-visible"> |
| <DialogHeader> |
| <div className="flex space-x-4 items-center mb-4"> |
| {!params.hide && ( |
| <button |
| type="button" |
| className="items-center border bg-accent p-1" |
| onClick={() => setParams({ step: "connect" })} |
| > |
| <Icons.ArrowBack /> |
| </button> |
| )} |
| <DialogTitle className="m-0 p-0"> |
| {page === "select-file" && "Select file"} |
| {page === "confirm-import" && "Confirm import"} |
| </DialogTitle> |
| </div> |
| <DialogDescription> |
| {page === "select-file" && |
| "Upload a CSV file of your transactions."} |
| {page === "confirm-import" && |
| "We've mapped each column to what we believe is correct, but please review the data below to confirm it's accurate."} |
| </DialogDescription> |
| </DialogHeader> |
| |
| <div className="relative overflow-visible"> |
| <AnimatedSizeContainer height> |
| <ImportCsvContext.Provider |
| value={{ |
| fileColumns, |
| setFileColumns, |
| firstRows, |
| setFirstRows, |
| control, |
| watch, |
| setValue, |
| }} |
| > |
| <div className="overflow-visible"> |
| <form |
| className="flex flex-col gap-y-4" |
| onSubmit={handleSubmit(async (data) => { |
| setIsImporting(true); |
| |
| const filename = stripSpecialCharacters(data.file.name); |
| const { path } = await uploadFile({ |
| bucket: "vault", |
| path: [user?.team?.id ?? "", "imports", filename], |
| file, |
| }); |
| |
| importTransactions.mutate({ |
| filePath: path, |
| currency: data.currency, |
| bankAccountId: data.bank_account_id, |
| currentBalance: data.balance, |
| inverted: data.inverted, |
| mappings: { |
| amount: data.amount, |
| date: data.date, |
| description: data.description, |
| balance: data.balance, |
| }, |
| }); |
| })} |
| > |
| {page === "select-file" && <SelectFile />} |
| {page === "confirm-import" && ( |
| <> |
| <FieldMapping currencies={uniqueCurrencies} /> |
| |
| <SubmitButton |
| isSubmitting={isImporting} |
| disabled={!isValid} |
| className="mt-4" |
| > |
| Confirm import |
| </SubmitButton> |
| |
| <button |
| type="button" |
| className="text-sm mb-4 text-[#878787]" |
| onClick={() => { |
| setPageNumber(0); |
| reset(); |
| setFileColumns(null); |
| setFirstRows(null); |
| }} |
| > |
| Choose another file |
| </button> |
| </> |
| )} |
| </form> |
| </div> |
| </ImportCsvContext.Provider> |
| </AnimatedSizeContainer> |
| </div> |
| </div> |
| </DialogContent> |
| </Dialog> |
| ); |
| } |
|
|