diff --git a/.changeset/legal-colts-shout.md b/.changeset/legal-colts-shout.md new file mode 100644 index 000000000..18d454cc5 --- /dev/null +++ b/.changeset/legal-colts-shout.md @@ -0,0 +1,5 @@ +--- +"@nmi-agro/fdm-helpdesk": minor +--- + +Helpdesk agents can now create and make use of saved replies using a new set of saved reply CRUD functions. There is also `makeSavedReplyBodySimple` which let agents extract a message body with template syntax, given a set of placeholders and the values assumed for them in the source text. There is also `applySavedReply` which can take the template text and fill in the template syntax with the values in the given context object. diff --git a/.changeset/sharp-ears-win.md b/.changeset/sharp-ears-win.md new file mode 100644 index 000000000..d3d0379b7 --- /dev/null +++ b/.changeset/sharp-ears-win.md @@ -0,0 +1,5 @@ +--- +"@nmi-agro/fdm-app": minor +--- + +Helpdesk agents can now create and use saved reply templates. They can either create a reply template from scratch, by writing the template body themselves with the placeholder syntax, or convert a message in an existing ticket to a reply template, which will add placeholder syntax automatically based on the context of the ticket. Afterwards they can choose a template when writing a new message, which will automatically fill in the placeholders according to the current ticket, and let the agent edit it further before sending. Agents can also edit their templates later or delete them, and also share their templates with other agents so they are able to use it for new messages too. diff --git a/fdm-app/app/components/blocks/header/helpdesk.tsx b/fdm-app/app/components/blocks/header/helpdesk.tsx index 8626621e3..69b053524 100644 --- a/fdm-app/app/components/blocks/header/helpdesk.tsx +++ b/fdm-app/app/components/blocks/header/helpdesk.tsx @@ -53,8 +53,8 @@ export function HeaderHelpdesk() { <> Instellingen - {/* - Opgeslagen reacties */} + + Opgeslagen reacties )} {currentHelpdeskPage === "agents" && ( diff --git a/fdm-app/app/components/blocks/helpdesk/message-composer.tsx b/fdm-app/app/components/blocks/helpdesk/message-composer.tsx index 7a133ee64..045d467d5 100644 --- a/fdm-app/app/components/blocks/helpdesk/message-composer.tsx +++ b/fdm-app/app/components/blocks/helpdesk/message-composer.tsx @@ -1,6 +1,7 @@ +import type { SavedReplyContext, SavedReplySummary } from "@nmi-agro/fdm-helpdesk" import type z from "zod" import { zodResolver } from "@hookform/resolvers/zod" -import { useId } from "react" +import { useEffect, useId, useRef } from "react" import { Controller, useWatch } from "react-hook-form" import { useFetcher } from "react-router" import { RemixFormProvider, useRemixForm } from "remix-hook-form" @@ -37,16 +38,24 @@ export function MessageComposer({ intent, principal, showAgentControls, + savedReplies, + savedReplyContext, + initialRows = 10, defaultValues = { ...formDefaultValues }, className, }: { intent: string principal: HelpdeskUser | null showAgentControls?: boolean + savedReplies?: SavedReplySummary[] + savedReplyContext?: SavedReplyContext + initialRows?: number defaultValues?: z.infer className?: string }) { const fetcher = useFetcher() + const savedReplyFetcher = useFetcher() + const hasSubmittedMessageRef = useRef(false) const form = useRemixForm({ mode: "onTouched", @@ -58,11 +67,46 @@ export function MessageComposer({ ...defaultValues, }, }) + + useEffect(() => { + if (fetcher.state === "submitting") { + hasSubmittedMessageRef.current = true + return + } + + if (fetcher.state !== "idle" || !hasSubmittedMessageRef.current) { + return + } + + hasSubmittedMessageRef.current = false + + if (fetcher.data?.ok === true) { + form.reset({ + ...formDefaultValues, + intent: intent, + ...defaultValues, + }) + } + }, [fetcher.state, fetcher.data, form, intent, defaultValues]) + const sender_role = useWatch({ name: "sender_role", control: form.control }) const is_internal = useWatch({ name: "is_internal", control: form.control }) const messageInputId = useId() + useEffect(() => { + if (typeof savedReplyFetcher.data?.body === "string") { + const current = form.getValues("body") + if (current) { + form.setValue("body", `${current}\n\n${savedReplyFetcher.data.body}`) + } else { + form.setValue("body", savedReplyFetcher.data.body) + } + } + }, [form.getValues, form.setValue, savedReplyFetcher.data]) + const isSubmitting = fetcher.state !== "idle" + const isFetchingSavedReply = savedReplyFetcher.state !== "idle" + const isDisabled = isSubmitting || isFetchingSavedReply return ( @@ -73,7 +117,7 @@ export function MessageComposer({ isInternal={is_internal} title={
@@ -103,7 +147,7 @@ export function MessageComposer({ checked={field.value as boolean} onCheckedChange={field.onChange} className="mt-1" - disabled={isSubmitting} + disabled={isDisabled} /> @@ -140,7 +184,37 @@ export function MessageComposer({
} > -
+
+ {savedReplies && savedReplies.length > 0 ? ( + + Gebruik een sjabloon: + + + ) : undefined} ( @@ -151,6 +225,7 @@ export function MessageComposer({ className="bg-card" id={messageInputId} placeholder={"Schrijf uw bericht hier..."} + rows={initialRows} /> {showAgentControls @@ -167,7 +242,7 @@ export function MessageComposer({ )} />
- diff --git a/fdm-app/app/components/blocks/helpdesk/message.tsx b/fdm-app/app/components/blocks/helpdesk/message.tsx index ab10d219a..503bf4f14 100644 --- a/fdm-app/app/components/blocks/helpdesk/message.tsx +++ b/fdm-app/app/components/blocks/helpdesk/message.tsx @@ -1,4 +1,4 @@ -import type { Message as MessageT } from "@nmi-agro/fdm-helpdesk" +import type { Message as MessageT, SavedReplyContext } from "@nmi-agro/fdm-helpdesk" import type { ReactNode } from "react" import { formatDate, formatDistanceToNow } from "date-fns" import { nl } from "date-fns/locale" @@ -6,6 +6,7 @@ import { cn } from "@/app/lib/utils" import { Card } from "~/components/ui/card" import type { HelpdeskUser } from "./types" import { HelpdeskUserAvatar } from "./helpdesk-user" +import { CreateSavedReplyDialog } from "./saved-reply-dialog" export type MessageExtended = MessageT & { principal: HelpdeskUser | null } export function Message({ @@ -15,6 +16,9 @@ export function Message({ todayDate, senderType, isInternal = false, + canSaveReply = false, + replyContext, + replyBody, className, children, }: { @@ -24,6 +28,9 @@ export function Message({ todayDate?: Date senderType?: string isInternal?: boolean + canSaveReply?: boolean + replyContext?: SavedReplyContext + replyBody?: string className?: string children: ReactNode }) { @@ -35,7 +42,7 @@ export function Message({ return ( )}
+ {canSaveReply && replyBody && ( + + )} {formattedDateTooltip && ( match.id === "routes/support.settings.profile")) return "profile" if (matches.some((match) => match.id === "routes/support.settings.agents")) return "agents" if (matches.some((match) => match.id === "routes/support.settings.absences")) return "absences" - - if (matches.some((match) => match.id === "routes/support.settings.saved-replies")) - return "saved_replies" if (matches.some((match) => match.id === "routes/support.settings.tags")) return "tags" + if (matches.some((match) => match.id.startsWith("routes/support.settings.saved-replies"))) + return "saved_replies" if (matches.some((match) => match.id === "routes/support.settings.blocked-emails")) return "blocked_emails" diff --git a/fdm-app/app/components/blocks/helpdesk/saved-reply-dialog.tsx b/fdm-app/app/components/blocks/helpdesk/saved-reply-dialog.tsx new file mode 100644 index 000000000..140806001 --- /dev/null +++ b/fdm-app/app/components/blocks/helpdesk/saved-reply-dialog.tsx @@ -0,0 +1,171 @@ +import type { SavedReplyContext } from "@nmi-agro/fdm-helpdesk" +import { zodResolver } from "@hookform/resolvers/zod" +import { useEffect, useState } from "react" +import { Controller, type Resolver } from "react-hook-form" +import { Form, useFetcher, useNavigation } from "react-router" +import { RemixFormProvider, useRemixForm } from "remix-hook-form" +import z from "zod" +import { Button } from "~/components/ui/button" +import { Checkbox } from "~/components/ui/checkbox" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog" +import { Field, FieldDescription, FieldError, FieldLabel } from "~/components/ui/field" +import { Input } from "~/components/ui/input" +import { Spinner } from "~/components/ui/spinner" +import { Textarea } from "~/components/ui/textarea" +import { CreateSavedReplySchema } from "./saved-reply-schema" + +const FormSchema = CreateSavedReplySchema.extend({ + intent: z.literal("create_saved_reply"), +}) + +export function CreateSavedReplyDialog({ + body, + context, + isInternal, +}: { + body: string + context?: SavedReplyContext + isInternal: boolean +}) { + const makeSavedReplyFetcher = useFetcher() + const navigation = useNavigation() + + const [dialogOpen, setDialogOpen] = useState(false) + + const form = useRemixForm({ + mode: "onTouched", + resolver: zodResolver(FormSchema) as Resolver>, + stringifyAllValues: false, + defaultValues: { + intent: "create_saved_reply", + title: "", + body: "", + }, + }) + + // biome-ignore lint/correctness/useExhaustiveDependencies: if a different message is rendered into the component, the dialog should close + useEffect(() => { + setDialogOpen(false) + }, [body]) + + useEffect(() => { + if (navigation.state === "loading") { + setDialogOpen(false) + } + }, [navigation.state]) + + useEffect(() => { + if (typeof makeSavedReplyFetcher.data?.body === "string") { + form.setValue("title", "") + form.setValue("body", makeSavedReplyFetcher.data.body) + setDialogOpen(true) + } + }, [form.setValue, makeSavedReplyFetcher.data]) + + const isSubmitting = navigation.state === "submitting" + const isMakingSavedReply = makeSavedReplyFetcher.state === "submitting" + + return ( + <> + + + {Object.entries(context ?? {}).map(([key, value]) => ( + + ))} + + + + + +
+
+ + Nieuwe sjabloon + + Maak een nieuwe opgeslagen reactie aan die je later kunt gebruiken bij het + beantwoorden van tickets. + + + {isInternal && ( +

+ Let op: dit antwoord kan interne notities bevatten. Zorg ervoor dat u deze niet + meeneemt wanneer u de sjabloon gebruikt. +

+ )} + ( + + Titel + Geef een titel voor de sjabloon. + + {fieldState.error ? : undefined} + + )} + /> + ( + + Sjabloontext + + Je kunt de sjabloon hier bewerken. {{customer_name}}, + {{agent_name}}, {{farm_name}}, + {{ticket_ref}} kunnen als plaatsaanduidingen voor de + corresponderende waarden worden gebruikt. + +