39998-vm/frontend/src/pages/conversations/conversations-edit.tsx
Flatlogic Bot 83cdb092cd 7
2026-05-15 19:17:21 +00:00

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;