diff --git a/apps/dashboard/.env-example b/apps/dashboard/.env-example index acef37169a..8b6e917cec 100644 --- a/apps/dashboard/.env-example +++ b/apps/dashboard/.env-example @@ -78,4 +78,7 @@ AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT= AZURE_DOCUMENT_INTELLIGENCE_KEY= # Google Maps -NEXT_PUBLIC_GOOGLE_API_KEY= \ No newline at end of file +NEXT_PUBLIC_GOOGLE_API_KEY= + +# VatCheckAPI +VATCHECKAPI_API_KEY= \ No newline at end of file diff --git a/apps/dashboard/src/actions/ai/editor/generate-editor-content.ts b/apps/dashboard/src/actions/ai/editor/generate-editor-content.ts new file mode 100644 index 0000000000..e2eee7df1d --- /dev/null +++ b/apps/dashboard/src/actions/ai/editor/generate-editor-content.ts @@ -0,0 +1,55 @@ +"use server"; + +import { openai } from "@ai-sdk/openai"; +import { streamText } from "ai"; +import { createStreamableValue } from "ai/rsc"; + +type Params = { + input: string; + context?: string; +}; + +export async function generateEditorContent({ input, context }: Params) { + console.log(context); + const stream = createStreamableValue(""); + + (async () => { + const { textStream } = await streamText({ + model: openai("gpt-4o-mini"), + prompt: input, + temperature: 0.8, + system: ` + You are an expert AI assistant specializing in invoice-related content generation and improvement. Your task is to enhance or modify invoice text based on specific instructions. Follow these guidelines: + + 1. Language: Always respond in the same language as the input prompt. + 2. Conciseness: Keep responses brief and precise, with a maximum of 200 characters. + 3. Context-awareness: Utilize any provided due date to inform payment terms. + + You will perform one of these primary functions: + - Fix grammar: Rectify any grammatical errors while preserving the original meaning. + - Improve text: Refine the text to improve clarity and professionalism. + - Condense text: Remove any unnecessary text and only keep the invoice-related content and make it more concise. + + If a due date is provided, incorporate it into the payment terms. + Adjust payment terms based on the due date, ensuring they are realistic and aligned with standard business practices. + + Format your response as plain text, using '\n' for line breaks when necessary. + Do not include any titles or headings in your response. + Provide only invoice-relevant content without any extraneous information. + Begin your response directly with the relevant invoice text or information. + + For custom prompts, maintain focus on invoice-related content. Ensure all generated text is appropriate for formal business communications and adheres to standard invoice practices. + Current date is: ${new Date().toISOString().split("T")[0]} \n + ${context} +`, + }); + + for await (const delta of textStream) { + stream.update(delta); + } + + stream.done(); + })(); + + return { output: stream.value }; +} diff --git a/apps/dashboard/src/actions/invoice/schema.ts b/apps/dashboard/src/actions/invoice/schema.ts index 3834542f30..bc2d10a562 100644 --- a/apps/dashboard/src/actions/invoice/schema.ts +++ b/apps/dashboard/src/actions/invoice/schema.ts @@ -18,10 +18,10 @@ export const updateInvoiceTemplateSchema = z.object({ tax_label: z.string().optional(), payment_details_label: z.string().optional(), note_label: z.string().optional(), - logo_url: z.string().optional(), + logo_url: z.string().optional().nullable(), currency: z.string().optional(), - payment_details: z.any(), - from_details: z.any(), + payment_details: z.any().nullable(), + from_details: z.any().nullable(), }); export const lineItemSchema = z.object({ diff --git a/apps/dashboard/src/actions/invoice/update-invoice-template-action.ts b/apps/dashboard/src/actions/invoice/update-invoice-template-action.ts index d0df8cb33e..ddf7dc9785 100644 --- a/apps/dashboard/src/actions/invoice/update-invoice-template-action.ts +++ b/apps/dashboard/src/actions/invoice/update-invoice-template-action.ts @@ -12,7 +12,7 @@ export const updateInvoiceTemplateAction = authActionClient .action(async ({ parsedInput: setting, ctx: { user, supabase } }) => { const teamId = user.team_id; - const { data, error } = await supabase + const { data } = await supabase .from("invoice_templates") .upsert({ team_id: teamId, ...setting }, { onConflict: "team_id" }) .eq("team_id", teamId) diff --git a/apps/dashboard/src/actions/validate-vat-number-action.ts b/apps/dashboard/src/actions/validate-vat-number-action.ts new file mode 100644 index 0000000000..6d09560b22 --- /dev/null +++ b/apps/dashboard/src/actions/validate-vat-number-action.ts @@ -0,0 +1,31 @@ +"use server"; + +import { z } from "zod"; +import { authActionClient } from "./safe-action"; + +const ENDPOINT = "https://api.vatcheckapi.com/v2/check"; + +export const validateVatNumberAction = authActionClient + .schema( + z.object({ + vat_number: z.string().min(7), + country_code: z.string(), + }), + ) + .metadata({ + name: "validate-vat-number", + }) + .action(async ({ parsedInput: { vat_number, country_code } }) => { + const response = await fetch( + `${ENDPOINT}?vat_number=${vat_number}&country_code=${country_code}&apikey=${process.env.VATCHECKAPI_API_KEY}`, + { + method: "GET", + }, + ); + + const data = await response.json(); + + console.log(data); + + return data; + }); diff --git a/apps/dashboard/src/components/country-selector.tsx b/apps/dashboard/src/components/country-selector.tsx index 9046fced7d..5a6e02e9ce 100644 --- a/apps/dashboard/src/components/country-selector.tsx +++ b/apps/dashboard/src/components/country-selector.tsx @@ -15,18 +15,25 @@ import { } from "@midday/ui/popover"; import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; import * as React from "react"; +import { useEffect } from "react"; type Props = { defaultValue: string; - onSelect: (countryCode: string) => void; + onSelect: (countryCode: string, countryName: string) => void; }; export function CountrySelector({ defaultValue, onSelect }: Props) { const [open, setOpen] = React.useState(false); const [value, setValue] = React.useState(defaultValue); + useEffect(() => { + if (value !== defaultValue) { + setValue(defaultValue); + } + }, [defaultValue, value]); + const selected = Object.values(countries).find( - (country) => country.code === value, + (country) => country.code === value || country.name === value, ); return ( @@ -34,7 +41,6 @@ export function CountrySelector({ defaultValue, onSelect }: Props) { + )} + + + {!isTypingPrompt && + selectors.map((selector, index) => ( + + + + ))} + + ); +} diff --git a/apps/dashboard/src/components/editor/selectors/ask-ai.tsx b/apps/dashboard/src/components/editor/selectors/ask-ai.tsx new file mode 100644 index 0000000000..ecae43234f --- /dev/null +++ b/apps/dashboard/src/components/editor/selectors/ask-ai.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { Button } from "@midday/ui/button"; +import { Icons } from "@midday/ui/icons"; +import { useEditor } from "novel"; + +type Props = { + onSelect: () => void; +}; + +export const AskAI = ({ onSelect }: Props) => { + const { editor } = useEditor(); + + if (!editor) return null; + + return ( + + ); +}; diff --git a/apps/dashboard/src/components/editor/selectors/link-selector.tsx b/apps/dashboard/src/components/editor/selectors/link-selector.tsx index a4ff092e55..df781f2e71 100644 --- a/apps/dashboard/src/components/editor/selectors/link-selector.tsx +++ b/apps/dashboard/src/components/editor/selectors/link-selector.tsx @@ -46,10 +46,11 @@ export const LinkSelector = ({ open, onOpenChange }: LinkSelectorProps) => { ); diff --git a/apps/dashboard/src/components/invoice/payment-details.tsx b/apps/dashboard/src/components/invoice/payment-details.tsx index 7e4e4dd151..3a3e024ddc 100644 --- a/apps/dashboard/src/components/invoice/payment-details.tsx +++ b/apps/dashboard/src/components/invoice/payment-details.tsx @@ -7,10 +7,13 @@ import { Controller, useFormContext } from "react-hook-form"; import { LabelInput } from "./label-input"; export function PaymentDetails() { - const { control } = useFormContext(); + const { control, watch } = useFormContext(); const updateInvoiceTemplate = useAction(updateInvoiceTemplateAction); + const dueDate = watch("due_date"); + const invoiceNumber = watch("invoice_number"); + return (
)} /> diff --git a/apps/dashboard/src/components/search-address-input.tsx b/apps/dashboard/src/components/search-address-input.tsx index 75380e07ce..c6f5fa678a 100644 --- a/apps/dashboard/src/components/search-address-input.tsx +++ b/apps/dashboard/src/components/search-address-input.tsx @@ -29,11 +29,11 @@ type Props = { export type AddressDetails = { address_line_1: string; - address_line_2: string; city: string; state: string; zip: string; country: string; + country_code: string; }; type Option = { @@ -67,6 +67,8 @@ const getAddressDetailsByAddressId = async ( comps?.find((c) => c.types.includes("postal_code"))?.long_name || ""; const country = comps?.find((c) => c.types.includes("country"))?.long_name || ""; + const countryCode = + comps?.find((c) => c.types.includes("country"))?.short_name || ""; return { address_line_1: `${streetNumber} ${streetAddress}`.trim(), @@ -74,6 +76,7 @@ const getAddressDetailsByAddressId = async ( state, zip, country, + country_code: countryCode, }; }; diff --git a/apps/dashboard/src/components/vat-number-input.tsx b/apps/dashboard/src/components/vat-number-input.tsx new file mode 100644 index 0000000000..2d77e33cbf --- /dev/null +++ b/apps/dashboard/src/components/vat-number-input.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { validateVatNumberAction } from "@/actions/validate-vat-number-action"; +import { Icons } from "@midday/ui/icons"; +import { Input } from "@midday/ui/input"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@midday/ui/tooltip"; +import { useDebounce } from "@uidotdev/usehooks"; +import { Loader2 } from "lucide-react"; +import { useAction } from "next-safe-action/hooks"; +import { useEffect, useState } from "react"; + +type Props = { + value: string; + onChange: (value: string) => void; + countryCode?: string; +}; + +export function VatNumberInput({ + value, + onChange, + countryCode, + ...props +}: Props) { + const [vatNumber, setVatNumber] = useState(value || ""); + const [companyName, setCompanyName] = useState(""); + const [isValid, setIsValid] = useState(undefined); + + const validateVatNumber = useAction(validateVatNumberAction, { + onSuccess: ({ data }) => { + if (data) { + setIsValid(data.format_valid); + setCompanyName(data?.registration_info?.name || ""); + } + }, + }); + + const debouncedVatNumber = useDebounce(vatNumber, 300); + + useEffect(() => { + if (debouncedVatNumber.length > 7 && countryCode) { + validateVatNumber.execute({ + vat_number: debouncedVatNumber, + country_code: countryCode, + }); + } + }, [debouncedVatNumber, countryCode]); + + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + + setVatNumber(newValue); + onChange?.(newValue); + setIsValid(undefined); + }; + + return ( +
+ + + {validateVatNumber.isExecuting && ( + + )} + + {isValid === true && ( + + + + + + {companyName && ( + +

{companyName.toLowerCase()}

+
+ )} +
+
+ )} + + {!validateVatNumber.isExecuting && isValid === false && ( + + + + + + + Invalid VAT number + + + + )} +
+ ); +} diff --git a/apps/dashboard/src/styles/globals.css b/apps/dashboard/src/styles/globals.css index abd75c16e7..16552eeed9 100644 --- a/apps/dashboard/src/styles/globals.css +++ b/apps/dashboard/src/styles/globals.css @@ -237,3 +237,11 @@ input[type="time"]::-webkit-calendar-picker-indicator { .invoice-editor .is-empty { height: 100%; } + +.ProseMirror-focused p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + float: left; + color: #434343; + pointer-events: none; + height: 0; +} diff --git a/packages/supabase/src/types/db.ts b/packages/supabase/src/types/db.ts index 4e66b6ccf2..bb5a60d7d3 100644 --- a/packages/supabase/src/types/db.ts +++ b/packages/supabase/src/types/db.ts @@ -198,13 +198,16 @@ export type Database = { address_line_2: string | null city: string | null country: string | null + country_code: string | null created_at: string - email: string | null + email: string id: string - name: string | null + name: string note: string | null + phone: string | null state: string | null team_id: string + vat_number: string | null website: string | null zip: string | null } @@ -213,13 +216,16 @@ export type Database = { address_line_2?: string | null city?: string | null country?: string | null + country_code?: string | null created_at?: string - email?: string | null + email: string id?: string - name?: string | null + name: string note?: string | null + phone?: string | null state?: string | null team_id?: string + vat_number?: string | null website?: string | null zip?: string | null } @@ -228,13 +234,16 @@ export type Database = { address_line_2?: string | null city?: string | null country?: string | null + country_code?: string | null created_at?: string - email?: string | null + email?: string id?: string - name?: string | null + name?: string note?: string | null + phone?: string | null state?: string | null team_id?: string + vat_number?: string | null website?: string | null zip?: string | null } diff --git a/packages/ui/src/components/icons.tsx b/packages/ui/src/components/icons.tsx index 8593f44beb..14f5c81dc6 100644 --- a/packages/ui/src/components/icons.tsx +++ b/packages/ui/src/components/icons.tsx @@ -54,11 +54,13 @@ import { MdOutlineCategory, MdOutlineChatBubbleOutline, MdOutlineClear, + MdOutlineCloseFullscreen, MdOutlineContentCopy, MdOutlineCreateNewFolder, MdOutlineDashboardCustomize, MdOutlineDelete, MdOutlineDescription, + MdOutlineDone, MdOutlineDownload, MdOutlineEditNote, MdOutlineEmail, @@ -84,6 +86,7 @@ import { MdOutlineQuestionAnswer, MdOutlineRepeat, MdOutlineSettings, + MdOutlineSpellcheck, MdOutlineStyle, MdOutlineSubject, MdOutlineTask, @@ -93,6 +96,7 @@ import { MdOutlineVisibility, MdOutlineVolumeOff, MdOutlineVolumeUp, + MdOutlineWrapText, MdPause, MdPauseCircle, MdPeople, @@ -646,4 +650,8 @@ export const Icons = { Strikethrough: MdFormatStrikethrough, AddLink: MdAddLink, DragIndicator: MdDragIndicator, + Condense: MdOutlineCloseFullscreen, + Done: MdOutlineDone, + Spellcheck: MdOutlineSpellcheck, + WrapText: MdOutlineWrapText, };