347 lines
15 KiB
TypeScript
347 lines
15 KiB
TypeScript
import {
|
|
mdiArrowLeft,
|
|
mdiAccountOutline,
|
|
mdiCalendarClockOutline,
|
|
mdiPinOutline,
|
|
mdiRobotOutline,
|
|
mdiTextBoxOutline,
|
|
mdiTimelineTextOutline,
|
|
} from '@mdi/js';
|
|
import Head from 'next/head';
|
|
import Link from 'next/link';
|
|
import React, { ReactElement, useEffect, useState } from 'react';
|
|
import { Field, Form, Formik } from 'formik';
|
|
|
|
import BaseIcon from '../../components/BaseIcon';
|
|
import {
|
|
actionButtonClassName,
|
|
EntityAsideCard,
|
|
EntityIntro,
|
|
EntityLinkCard,
|
|
EntitySection,
|
|
formatDateTime,
|
|
formatName,
|
|
formatRole,
|
|
inputClassName,
|
|
textAreaClassName,
|
|
} from '../../components/AdminEntity/PageKit';
|
|
import { SelectField } from '../../components/SelectField';
|
|
import { SwitchField } from '../../components/SwitchField';
|
|
import SectionMain from '../../components/SectionMain';
|
|
import { getPageTitle } from '../../config';
|
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
|
import { fetch, update } from '../../stores/conversations/conversationsSlice';
|
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
|
import { useRouter } from 'next/router';
|
|
|
|
const initialConversationValues = {
|
|
agent: null,
|
|
client_context_json: '',
|
|
is_pinned: false,
|
|
last_message_at: '',
|
|
status: 'active',
|
|
summary: '',
|
|
title: '',
|
|
user: null,
|
|
};
|
|
|
|
const statusOptions = ['active', 'archived', 'deleted'];
|
|
|
|
function toDateTimeInputValue(value: any) {
|
|
if (!value) {
|
|
return '';
|
|
}
|
|
|
|
const date = new Date(value);
|
|
|
|
if (Number.isNaN(date.getTime())) {
|
|
return '';
|
|
}
|
|
|
|
const year = date.getFullYear();
|
|
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
|
const day = `${date.getDate()}`.padStart(2, '0');
|
|
const hours = `${date.getHours()}`.padStart(2, '0');
|
|
const minutes = `${date.getMinutes()}`.padStart(2, '0');
|
|
|
|
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
|
}
|
|
|
|
function normalizeConversationValues(conversation: any) {
|
|
return {
|
|
agent: conversation?.agent || null,
|
|
client_context_json: conversation?.client_context_json || '',
|
|
is_pinned: Boolean(conversation?.is_pinned),
|
|
last_message_at: toDateTimeInputValue(conversation?.last_message_at),
|
|
status: conversation?.status || 'active',
|
|
summary: conversation?.summary || '',
|
|
title: conversation?.title || '',
|
|
user: conversation?.user || null,
|
|
};
|
|
}
|
|
|
|
const EditConversationsPage = () => {
|
|
const router = useRouter();
|
|
const dispatch = useAppDispatch();
|
|
const { conversations, loading } = useAppSelector((state) => state.conversations);
|
|
const { id } = router.query;
|
|
const [initialValues, setInitialValues] = useState(initialConversationValues);
|
|
|
|
useEffect(() => {
|
|
if (typeof id !== 'string') {
|
|
return;
|
|
}
|
|
|
|
dispatch(fetch({ id }));
|
|
}, [dispatch, id]);
|
|
|
|
useEffect(() => {
|
|
if (!conversations || Array.isArray(conversations) || typeof conversations !== 'object') {
|
|
return;
|
|
}
|
|
|
|
setInitialValues(normalizeConversationValues(conversations));
|
|
}, [conversations]);
|
|
|
|
const handleSubmit = async (data: typeof initialConversationValues) => {
|
|
if (typeof id !== 'string') {
|
|
return;
|
|
}
|
|
|
|
await dispatch(update({ id, data }));
|
|
await router.push(`/conversations/conversations-view/?id=${id}`);
|
|
};
|
|
|
|
const conversation =
|
|
!Array.isArray(conversations) && conversations && typeof conversations === 'object'
|
|
? conversations
|
|
: null;
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>{getPageTitle('Edit conversation')}</title>
|
|
</Head>
|
|
<SectionMain>
|
|
<Formik enableReinitialize initialValues={initialValues} onSubmit={(values) => handleSubmit(values)}>
|
|
{({ isSubmitting, values }) => {
|
|
const isConversationLoading = loading && !initialValues.title && !initialValues.summary;
|
|
|
|
return (
|
|
<Form>
|
|
<div className="flex w-full flex-col gap-5">
|
|
<EntityIntro
|
|
backHref={typeof id === 'string' ? `/conversations/conversations-view/?id=${id}` : '/conversations/conversations-list'}
|
|
backLabel="Back to conversation"
|
|
description="Update ownership, status, and saved context without going back to the old generated admin form."
|
|
kicker="Conversation"
|
|
title="Refine this conversation."
|
|
/>
|
|
|
|
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_320px]">
|
|
<div className="space-y-5">
|
|
<EntitySection
|
|
description="Keep ownership and agent assignment explicit so the thread remains easy to audit."
|
|
icon={mdiAccountOutline}
|
|
title="Linked records"
|
|
>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div>
|
|
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="user">
|
|
User
|
|
</label>
|
|
<Field
|
|
component={SelectField}
|
|
id="user"
|
|
itemRef="users"
|
|
name="user"
|
|
options={values.user}
|
|
showField="firstName"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="agent">
|
|
Agent
|
|
</label>
|
|
<Field
|
|
component={SelectField}
|
|
id="agent"
|
|
itemRef="agents"
|
|
name="agent"
|
|
options={values.agent}
|
|
showField="name"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</EntitySection>
|
|
|
|
<EntitySection
|
|
description="Edit how this thread is titled and summarized in admin surfaces and the workspace."
|
|
icon={mdiTextBoxOutline}
|
|
title="Content"
|
|
>
|
|
<div className="grid gap-4">
|
|
<div>
|
|
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="title">
|
|
Title
|
|
</label>
|
|
<Field className={inputClassName} id="title" name="title" />
|
|
</div>
|
|
<div>
|
|
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="summary">
|
|
Summary
|
|
</label>
|
|
<Field as="textarea" className={textAreaClassName} id="summary" name="summary" />
|
|
</div>
|
|
<div>
|
|
<label
|
|
className="mb-2 block text-[13px] font-medium text-slate-700"
|
|
htmlFor="client_context_json"
|
|
>
|
|
Client context JSON
|
|
</label>
|
|
<Field
|
|
as="textarea"
|
|
className={textAreaClassName}
|
|
id="client_context_json"
|
|
name="client_context_json"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</EntitySection>
|
|
|
|
<EntitySection
|
|
description="Control the lifecycle of the thread, whether it stays pinned, and the recorded timestamp of the last message."
|
|
icon={mdiTimelineTextOutline}
|
|
title="State"
|
|
>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div>
|
|
<label className="mb-2 block text-[13px] font-medium text-slate-700" htmlFor="status">
|
|
Status
|
|
</label>
|
|
<Field as="select" className={inputClassName} id="status" name="status">
|
|
{statusOptions.map((option) => (
|
|
<option key={option} value={option}>
|
|
{formatRole(option)}
|
|
</option>
|
|
))}
|
|
</Field>
|
|
</div>
|
|
<div>
|
|
<label
|
|
className="mb-2 block text-[13px] font-medium text-slate-700"
|
|
htmlFor="last_message_at"
|
|
>
|
|
Last message at
|
|
</label>
|
|
<Field
|
|
className={inputClassName}
|
|
id="last_message_at"
|
|
name="last_message_at"
|
|
type="datetime-local"
|
|
/>
|
|
</div>
|
|
<div className="rounded-[10px] border border-slate-200 px-4 py-3 md:col-span-2">
|
|
<label className="mb-3 block text-[13px] font-medium text-slate-700" htmlFor="is_pinned">
|
|
Pin to top
|
|
</label>
|
|
<Field component={SwitchField} id="is_pinned" name="is_pinned" />
|
|
</div>
|
|
</div>
|
|
</EntitySection>
|
|
</div>
|
|
|
|
<div className="space-y-5">
|
|
<EntityAsideCard title="Preview">
|
|
<div className="space-y-3">
|
|
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
|
|
<div className="flex items-center gap-2 text-slate-500">
|
|
<BaseIcon path={mdiTextBoxOutline} size={16} />
|
|
<p className="text-[12px] font-medium uppercase tracking-[0.16em]">Conversation</p>
|
|
</div>
|
|
<p className="mt-2 text-[16px] font-semibold text-slate-900">
|
|
{values.title || 'Untitled conversation'}
|
|
</p>
|
|
<p className="mt-2 text-[13px] leading-6 text-slate-500">
|
|
{values.summary || 'No summary yet.'}
|
|
</p>
|
|
</div>
|
|
<EntityLinkCard
|
|
description={conversation?.user?.email || 'Conversation owner'}
|
|
href={conversation?.user?.id ? `/users/users-view/?id=${conversation.user.id}` : undefined}
|
|
icon={mdiAccountOutline}
|
|
label="Owner"
|
|
value={formatName(conversation?.user)}
|
|
/>
|
|
<EntityLinkCard
|
|
description="Assigned assistant"
|
|
href={conversation?.agent?.id ? `/agents/agents-view/?id=${conversation.agent.id}` : undefined}
|
|
icon={mdiRobotOutline}
|
|
label="Agent"
|
|
value={conversation?.agent?.name || 'No agent'}
|
|
/>
|
|
</div>
|
|
</EntityAsideCard>
|
|
|
|
<EntityAsideCard title="At a glance">
|
|
<div className="space-y-3">
|
|
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
|
|
<div className="flex items-center gap-2 text-slate-500">
|
|
<BaseIcon path={mdiPinOutline} size={16} />
|
|
<p className="text-[12px] font-medium uppercase tracking-[0.16em]">Pinned</p>
|
|
</div>
|
|
<p className="mt-2 text-[15px] font-medium text-slate-900">
|
|
{values.is_pinned ? 'Pinned to top' : 'Regular thread'}
|
|
</p>
|
|
</div>
|
|
<div className="rounded-[10px] border border-slate-200 px-4 py-3">
|
|
<div className="flex items-center gap-2 text-slate-500">
|
|
<BaseIcon path={mdiCalendarClockOutline} size={16} />
|
|
<p className="text-[12px] font-medium uppercase tracking-[0.16em]">Last activity</p>
|
|
</div>
|
|
<p className="mt-2 text-[15px] font-medium text-slate-900">
|
|
{formatDateTime(values.last_message_at)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</EntityAsideCard>
|
|
|
|
<EntityAsideCard title="Actions">
|
|
{isConversationLoading && (
|
|
<p className="text-[13px] text-slate-500">Loading conversation…</p>
|
|
)}
|
|
<div className="flex flex-wrap gap-2">
|
|
<Link
|
|
className={`${actionButtonClassName} border-slate-200 bg-white text-slate-700`}
|
|
href={typeof id === 'string' ? `/conversations/conversations-view/?id=${id}` : '/conversations/conversations-list'}
|
|
>
|
|
Cancel
|
|
</Link>
|
|
<button
|
|
className={`${actionButtonClassName} border-slate-900 bg-slate-900 text-white`}
|
|
disabled={isSubmitting || isConversationLoading}
|
|
type="submit"
|
|
>
|
|
{isSubmitting ? 'Saving…' : 'Save changes'}
|
|
</button>
|
|
</div>
|
|
</EntityAsideCard>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Form>
|
|
);
|
|
}}
|
|
</Formik>
|
|
</SectionMain>
|
|
</>
|
|
);
|
|
};
|
|
|
|
EditConversationsPage.getLayout = function getLayout(page: ReactElement) {
|
|
return <LayoutAuthenticated permission="UPDATE_CONVERSATIONS">{page}</LayoutAuthenticated>;
|
|
};
|
|
|
|
export default EditConversationsPage;
|