This commit is contained in:
2026-03-14 16:50:03 -05:00
parent a8d0533f4a
commit 70f55c98b5
12 changed files with 477 additions and 11 deletions

View File

@@ -9,7 +9,8 @@
--font-family: "Manrope";
--color-brand: 24 90 219;
--color-accent: 0 166 166;
--color-surface: 244 247 251;
--color-surface-brand: 244 247 251;
--color-surface: var(--color-surface-brand);
--color-page: 248 250 252;
--color-text: 15 23 42;
--color-muted: 90 106 133;

View File

@@ -9,6 +9,8 @@ import type {
LoginResponse,
} from "@mrp/shared";
import type {
CrmContactEntryDto,
CrmContactEntryInput,
CrmRecordDetailDto,
CrmRecordInput,
CrmRecordStatus,
@@ -140,6 +142,16 @@ export const api = {
token
);
},
createCustomerContactEntry(token: string, customerId: string, payload: CrmContactEntryInput) {
return request<CrmContactEntryDto>(
`/api/v1/crm/customers/${customerId}/contact-history`,
{
method: "POST",
body: JSON.stringify(payload),
},
token
);
},
getVendors(token: string, filters?: { q?: string; status?: CrmRecordStatus; state?: string }) {
return request<CrmRecordSummaryDto[]>(
`/api/v1/crm/vendors${buildQueryString({
@@ -174,6 +186,16 @@ export const api = {
token
);
},
createVendorContactEntry(token: string, vendorId: string, payload: CrmContactEntryInput) {
return request<CrmContactEntryDto>(
`/api/v1/crm/vendors/${vendorId}/contact-history`,
{
method: "POST",
body: JSON.stringify(payload),
},
token
);
},
getGanttDemo(token: string) {
return request<{ tasks: GanttTaskDto[]; links: GanttLinkDto[] }>("/api/v1/gantt/demo", undefined, token);
},

View File

@@ -0,0 +1,72 @@
import type { CrmContactEntryInput } from "@mrp/shared/dist/crm/types.js";
import { crmContactTypeOptions } from "./config";
interface CrmContactEntryFormProps {
form: CrmContactEntryInput;
isSaving: boolean;
status: string;
onChange: <Key extends keyof CrmContactEntryInput>(key: Key, value: CrmContactEntryInput[Key]) => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
}
export function CrmContactEntryForm({ form, isSaving, status, onChange, onSubmit }: CrmContactEntryFormProps) {
return (
<form className="space-y-5" onSubmit={onSubmit}>
<div className="grid gap-5 md:grid-cols-[0.9fr_1.1fr]">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Interaction type</span>
<select
value={form.type}
onChange={(event) => onChange("type", event.target.value as CrmContactEntryInput["type"])}
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
>
{crmContactTypeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Contact timestamp</span>
<input
type="datetime-local"
value={form.contactAt.slice(0, 16)}
onChange={(event) => onChange("contactAt", new Date(event.target.value).toISOString())}
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
/>
</label>
</div>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Summary</span>
<input
value={form.summary}
onChange={(event) => onChange("summary", event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
placeholder="Short headline for the interaction"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Details</span>
<textarea
value={form.body}
onChange={(event) => onChange("body", event.target.value)}
rows={5}
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
placeholder="Capture what happened, follow-ups, and commitments."
/>
</label>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/70 px-4 py-4">
<span className="text-sm text-muted">{status}</span>
<button
type="submit"
disabled={isSaving}
className="rounded-2xl bg-brand px-5 py-3 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
>
{isSaving ? "Saving..." : "Add entry"}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,13 @@
import type { CrmContactEntryType } from "@mrp/shared/dist/crm/types.js";
import { crmContactTypeOptions, crmContactTypePalette } from "./config";
export function CrmContactTypeBadge({ type }: { type: CrmContactEntryType }) {
const label = crmContactTypeOptions.find((option) => option.value === type)?.label ?? type;
return (
<span className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${crmContactTypePalette[type]}`}>
{label}
</span>
);
}

View File

@@ -1,12 +1,14 @@
import { permissions } from "@mrp/shared";
import type { CrmRecordDetailDto } from "@mrp/shared/dist/crm/types.js";
import type { CrmContactEntryInput, CrmRecordDetailDto } from "@mrp/shared/dist/crm/types.js";
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { CrmContactEntryForm } from "./CrmContactEntryForm";
import { CrmContactTypeBadge } from "./CrmContactTypeBadge";
import { CrmStatusBadge } from "./CrmStatusBadge";
import { type CrmEntity, crmConfigs } from "./config";
import { type CrmEntity, crmConfigs, emptyCrmContactEntryInput } from "./config";
interface CrmDetailPageProps {
entity: CrmEntity;
@@ -19,6 +21,9 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
const config = crmConfigs[entity];
const [record, setRecord] = useState<CrmRecordDetailDto | null>(null);
const [status, setStatus] = useState(`Loading ${config.singularLabel.toLowerCase()}...`);
const [contactEntryForm, setContactEntryForm] = useState<CrmContactEntryInput>(emptyCrmContactEntryInput);
const [contactEntryStatus, setContactEntryStatus] = useState("Add a timeline entry for this account.");
const [isSavingContactEntry, setIsSavingContactEntry] = useState(false);
const canManage = user?.permissions.includes(permissions.crmWrite) ?? false;
@@ -33,6 +38,7 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
.then((nextRecord) => {
setRecord(nextRecord);
setStatus(`${config.singularLabel} record loaded.`);
setContactEntryStatus("Add a timeline entry for this account.");
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : `Unable to load ${config.singularLabel.toLowerCase()}.`;
@@ -44,6 +50,48 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-8 text-sm text-muted shadow-panel">{status}</div>;
}
function updateContactEntryField<Key extends keyof CrmContactEntryInput>(key: Key, value: CrmContactEntryInput[Key]) {
setContactEntryForm((current) => ({ ...current, [key]: value }));
}
async function handleContactEntrySubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token || !recordId) {
return;
}
setIsSavingContactEntry(true);
setContactEntryStatus("Saving timeline entry...");
try {
const nextEntry =
entity === "customer"
? await api.createCustomerContactEntry(token, recordId, contactEntryForm)
: await api.createVendorContactEntry(token, recordId, contactEntryForm);
setRecord((current) =>
current
? {
...current,
contactHistory: [nextEntry, ...current.contactHistory].sort(
(left, right) => new Date(right.contactAt).getTime() - new Date(left.contactAt).getTime()
),
}
: current
);
setContactEntryForm({
...emptyCrmContactEntryInput,
contactAt: new Date().toISOString(),
});
setContactEntryStatus("Timeline entry added.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save timeline entry.";
setContactEntryStatus(message);
} finally {
setIsSavingContactEntry(false);
}
}
return (
<section className="space-y-6">
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel">
@@ -76,7 +124,7 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
</div>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Contact</p>
<dl className="mt-6 grid gap-5 md:grid-cols-2">
@@ -99,7 +147,7 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
</dl>
</article>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Notes</p>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Internal Notes</p>
<p className="mt-4 whitespace-pre-line text-sm leading-7 text-text">
{record.notes || "No internal notes recorded for this account yet."}
</p>
@@ -108,6 +156,55 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
</div>
</article>
</div>
<section className="grid gap-6 xl:grid-cols-[0.85fr_1.15fr]">
{canManage ? (
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Contact History</p>
<h4 className="mt-3 text-xl font-bold text-text">Add timeline entry</h4>
<p className="mt-2 text-sm text-muted">
Record calls, emails, meetings, and follow-up notes directly against this account.
</p>
<div className="mt-6">
<CrmContactEntryForm
form={contactEntryForm}
isSaving={isSavingContactEntry}
status={contactEntryStatus}
onChange={updateContactEntryField}
onSubmit={handleContactEntrySubmit}
/>
</div>
</article>
) : null}
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Timeline</p>
<h4 className="mt-3 text-xl font-bold text-text">Recent interactions</h4>
{record.contactHistory.length === 0 ? (
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-6 py-12 text-center text-sm text-muted">
No contact history has been recorded for this account yet.
</div>
) : (
<div className="mt-6 space-y-4">
{record.contactHistory.map((entry) => (
<article key={entry.id} className="rounded-3xl border border-line/70 bg-page/60 p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="flex flex-wrap items-center gap-3">
<CrmContactTypeBadge type={entry.type} />
<h5 className="text-base font-semibold text-text">{entry.summary}</h5>
</div>
<p className="mt-3 whitespace-pre-line text-sm leading-7 text-text">{entry.body}</p>
</div>
<div className="text-sm text-muted lg:text-right">
<div>{new Date(entry.contactAt).toLocaleString()}</div>
<div className="mt-1">{entry.createdBy.name}</div>
</div>
</div>
</article>
))}
</div>
)}
</article>
</section>
</section>
);
}

View File

@@ -1,4 +1,11 @@
import { crmRecordStatuses, type CrmRecordInput, type CrmRecordStatus } from "@mrp/shared/dist/crm/types.js";
import {
crmContactEntryTypes,
crmRecordStatuses,
type CrmContactEntryInput,
type CrmContactEntryType,
type CrmRecordInput,
type CrmRecordStatus,
} from "@mrp/shared/dist/crm/types.js";
export type CrmEntity = "customer" | "vendor";
@@ -60,4 +67,25 @@ export const crmStatusPalette: Record<CrmRecordStatus, string> = {
INACTIVE: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
};
export { crmRecordStatuses };
export const emptyCrmContactEntryInput: CrmContactEntryInput = {
type: "NOTE",
summary: "",
body: "",
contactAt: new Date().toISOString(),
};
export const crmContactTypeOptions: Array<{ value: CrmContactEntryType; label: string }> = [
{ value: "NOTE", label: "Note" },
{ value: "CALL", label: "Call" },
{ value: "EMAIL", label: "Email" },
{ value: "MEETING", label: "Meeting" },
];
export const crmContactTypePalette: Record<CrmContactEntryType, string> = {
NOTE: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
CALL: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
EMAIL: "border border-violet-400/30 bg-violet-500/12 text-violet-700 dark:text-violet-300",
MEETING: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
};
export { crmContactEntryTypes, crmRecordStatuses };

View File

@@ -33,7 +33,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
document.documentElement.style.setProperty("--color-brand", hexToRgbTriplet(profile.theme.primaryColor));
document.documentElement.style.setProperty("--color-accent", hexToRgbTriplet(profile.theme.accentColor));
document.documentElement.style.setProperty("--color-surface", hexToRgbTriplet(profile.theme.surfaceColor));
document.documentElement.style.setProperty("--color-surface-brand", hexToRgbTriplet(profile.theme.surfaceColor));
document.documentElement.style.setProperty("--font-family", profile.theme.fontFamily);
};

View File

@@ -0,0 +1,19 @@
CREATE TABLE "CrmContactEntry" (
"id" TEXT NOT NULL PRIMARY KEY,
"type" TEXT NOT NULL DEFAULT 'NOTE',
"summary" TEXT NOT NULL,
"body" TEXT NOT NULL,
"contactAt" DATETIME NOT NULL,
"customerId" TEXT,
"vendorId" TEXT,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "CrmContactEntry_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "CrmContactEntry_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "CrmContactEntry_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE INDEX "CrmContactEntry_customerId_contactAt_idx" ON "CrmContactEntry"("customerId", "contactAt");
CREATE INDEX "CrmContactEntry_vendorId_contactAt_idx" ON "CrmContactEntry"("vendorId", "contactAt");

View File

@@ -18,6 +18,7 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userRoles UserRole[]
contactEntries CrmContactEntry[]
}
model Role {
@@ -115,6 +116,7 @@ model Customer {
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contactEntries CrmContactEntry[]
}
model Vendor {
@@ -132,4 +134,21 @@ model Vendor {
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contactEntries CrmContactEntry[]
}
model CrmContactEntry {
id String @id @default(cuid())
type String @default("NOTE")
summary String
body String
contactAt DateTime
customerId String?
vendorId String?
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer? @relation(fields: [customerId], references: [id], onDelete: Cascade)
vendor Vendor? @relation(fields: [vendorId], references: [id], onDelete: Cascade)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
}

View File

@@ -1,11 +1,14 @@
import { crmRecordStatuses, permissions } from "@mrp/shared";
import { permissions } from "@mrp/shared";
import { crmContactEntryTypes, crmRecordStatuses } from "@mrp/shared/dist/crm/types.js";
import { Router } from "express";
import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import {
createCustomerContactEntry,
createCustomer,
createVendorContactEntry,
createVendor,
getCustomerById,
getVendorById,
@@ -35,6 +38,13 @@ const crmListQuerySchema = z.object({
status: z.enum(crmRecordStatuses).optional(),
});
const crmContactEntrySchema = z.object({
type: z.enum(crmContactEntryTypes),
summary: z.string().trim().min(1).max(160),
body: z.string().trim().min(1).max(4000),
contactAt: z.string().datetime(),
});
function getRouteParam(value: string | string[] | undefined) {
return typeof value === "string" ? value : null;
}
@@ -99,6 +109,25 @@ crmRouter.put("/customers/:customerId", requirePermissions([permissions.crmWrite
return ok(response, customer);
});
crmRouter.post("/customers/:customerId/contact-history", requirePermissions([permissions.crmWrite]), async (request, response) => {
const customerId = getRouteParam(request.params.customerId);
if (!customerId) {
return fail(response, 400, "INVALID_INPUT", "Customer id is invalid.");
}
const parsed = crmContactEntrySchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Contact history entry is invalid.");
}
const entry = await createCustomerContactEntry(customerId, parsed.data, request.authUser?.id);
if (!entry) {
return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found.");
}
return ok(response, entry, 201);
});
crmRouter.get("/vendors", requirePermissions([permissions.crmRead]), async (_request, response) => {
const parsed = crmListQuerySchema.safeParse(_request.query);
if (!parsed.success) {
@@ -156,3 +185,22 @@ crmRouter.put("/vendors/:vendorId", requirePermissions([permissions.crmWrite]),
return ok(response, vendor);
});
crmRouter.post("/vendors/:vendorId/contact-history", requirePermissions([permissions.crmWrite]), async (request, response) => {
const vendorId = getRouteParam(request.params.vendorId);
if (!vendorId) {
return fail(response, 400, "INVALID_INPUT", "Vendor id is invalid.");
}
const parsed = crmContactEntrySchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Contact history entry is invalid.");
}
const entry = await createVendorContactEntry(vendorId, parsed.data, request.authUser?.id);
if (!entry) {
return fail(response, 404, "CRM_VENDOR_NOT_FOUND", "Vendor record was not found.");
}
return ok(response, entry, 201);
});

View File

@@ -1,4 +1,7 @@
import type {
CrmContactEntryDto,
CrmContactEntryInput,
CrmContactEntryType,
CrmRecordDetailDto,
CrmRecordInput,
CrmRecordStatus,
@@ -30,6 +33,58 @@ function mapDetail(record: Customer | Vendor): CrmRecordDetailDto {
postalCode: record.postalCode,
notes: record.notes,
createdAt: record.createdAt.toISOString(),
contactHistory: [],
};
}
type ContactEntryWithAuthor = {
id: string;
type: string;
summary: string;
body: string;
contactAt: Date;
createdAt: Date;
createdBy: {
id: string;
firstName: string;
lastName: string;
email: string;
} | null;
};
type DetailedRecord = (Customer | Vendor) & {
contactEntries: ContactEntryWithAuthor[];
};
function mapContactEntry(entry: ContactEntryWithAuthor): CrmContactEntryDto {
return {
id: entry.id,
type: entry.type as CrmContactEntryType,
summary: entry.summary,
body: entry.body,
contactAt: entry.contactAt.toISOString(),
createdAt: entry.createdAt.toISOString(),
createdBy: entry.createdBy
? {
id: entry.createdBy.id,
name: `${entry.createdBy.firstName} ${entry.createdBy.lastName}`.trim(),
email: entry.createdBy.email,
}
: {
id: null,
name: "System",
email: null,
},
};
}
function mapDetailedRecord(record: DetailedRecord): CrmRecordDetailDto {
return {
...mapDetail(record),
contactHistory: record.contactEntries
.slice()
.sort((left, right) => right.contactAt.getTime() - left.contactAt.getTime())
.map(mapContactEntry),
};
}
@@ -74,9 +129,17 @@ export async function listCustomers(filters: CrmListFilters = {}) {
export async function getCustomerById(customerId: string) {
const customer = await prisma.customer.findUnique({
where: { id: customerId },
include: {
contactEntries: {
include: {
createdBy: true,
},
orderBy: [{ contactAt: "desc" }, { createdAt: "desc" }],
},
},
});
return customer ? mapDetail(customer) : null;
return customer ? mapDetailedRecord(customer) : null;
}
export async function createCustomer(payload: CrmRecordInput) {
@@ -116,9 +179,17 @@ export async function listVendors(filters: CrmListFilters = {}) {
export async function getVendorById(vendorId: string) {
const vendor = await prisma.vendor.findUnique({
where: { id: vendorId },
include: {
contactEntries: {
include: {
createdBy: true,
},
orderBy: [{ contactAt: "desc" }, { createdAt: "desc" }],
},
},
});
return vendor ? mapDetail(vendor) : null;
return vendor ? mapDetailedRecord(vendor) : null;
}
export async function createVendor(payload: CrmRecordInput) {
@@ -145,3 +216,55 @@ export async function updateVendor(vendorId: string, payload: CrmRecordInput) {
return mapDetail(vendor);
}
export async function createCustomerContactEntry(customerId: string, payload: CrmContactEntryInput, createdById?: string) {
const existingCustomer = await prisma.customer.findUnique({
where: { id: customerId },
});
if (!existingCustomer) {
return null;
}
const entry = await prisma.crmContactEntry.create({
data: {
type: payload.type,
summary: payload.summary,
body: payload.body,
contactAt: new Date(payload.contactAt),
customerId,
createdById,
},
include: {
createdBy: true,
},
});
return mapContactEntry(entry);
}
export async function createVendorContactEntry(vendorId: string, payload: CrmContactEntryInput, createdById?: string) {
const existingVendor = await prisma.vendor.findUnique({
where: { id: vendorId },
});
if (!existingVendor) {
return null;
}
const entry = await prisma.crmContactEntry.create({
data: {
type: payload.type,
summary: payload.summary,
body: payload.body,
contactAt: new Date(payload.contactAt),
vendorId,
createdById,
},
include: {
createdBy: true,
},
});
return mapContactEntry(entry);
}

View File

@@ -1,6 +1,29 @@
export const crmRecordStatuses = ["LEAD", "ACTIVE", "ON_HOLD", "INACTIVE"] as const;
export const crmContactEntryTypes = ["NOTE", "CALL", "EMAIL", "MEETING"] as const;
export type CrmRecordStatus = (typeof crmRecordStatuses)[number];
export type CrmContactEntryType = (typeof crmContactEntryTypes)[number];
export interface CrmContactEntryDto {
id: string;
type: CrmContactEntryType;
summary: string;
body: string;
contactAt: string;
createdAt: string;
createdBy: {
id: string | null;
name: string;
email: string | null;
};
}
export interface CrmContactEntryInput {
type: CrmContactEntryType;
summary: string;
body: string;
contactAt: string;
}
export interface CrmRecordSummaryDto {
id: string;
@@ -20,6 +43,7 @@ export interface CrmRecordDetailDto extends CrmRecordSummaryDto {
postalCode: string;
notes: string;
createdAt: string;
contactHistory: CrmContactEntryDto[];
}
export interface CrmRecordInput {