This commit is contained in:
2026-03-14 18:46:06 -05:00
parent f1fd2ed979
commit c0cc546e33
15 changed files with 979 additions and 27 deletions

View File

@@ -9,8 +9,11 @@ import type {
LoginResponse,
} from "@mrp/shared";
import type {
CrmContactDto,
CrmContactInput,
CrmContactEntryDto,
CrmContactEntryInput,
CrmCustomerHierarchyOptionDto,
CrmRecordDetailDto,
CrmRecordInput,
CrmRecordStatus,
@@ -132,6 +135,15 @@ export const api = {
getCustomer(token: string, customerId: string) {
return request<CrmRecordDetailDto>(`/api/v1/crm/customers/${customerId}`, undefined, token);
},
getCustomerHierarchyOptions(token: string, excludeCustomerId?: string) {
return request<CrmCustomerHierarchyOptionDto[]>(
`/api/v1/crm/customers/hierarchy-options${buildQueryString({
excludeCustomerId,
})}`,
undefined,
token
);
},
createCustomer(token: string, payload: CrmRecordInput) {
return request<CrmRecordDetailDto>(
"/api/v1/crm/customers",
@@ -162,6 +174,16 @@ export const api = {
token
);
},
createCustomerContact(token: string, customerId: string, payload: CrmContactInput) {
return request<CrmContactDto>(
`/api/v1/crm/customers/${customerId}/contacts`,
{
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({
@@ -206,6 +228,16 @@ export const api = {
token
);
},
createVendorContact(token: string, vendorId: string, payload: CrmContactInput) {
return request<CrmContactDto>(
`/api/v1/crm/vendors/${vendorId}/contacts`,
{
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,153 @@
import type { CrmContactDto, CrmContactInput } from "@mrp/shared/dist/crm/types.js";
import { permissions } from "@mrp/shared";
import { useState } from "react";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { crmContactRoleOptions, emptyCrmContactInput, type CrmEntity } from "./config";
interface CrmContactsPanelProps {
entity: CrmEntity;
ownerId: string;
contacts: CrmContactDto[];
onContactsChange: (contacts: CrmContactDto[]) => void;
}
export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }: CrmContactsPanelProps) {
const { token, user } = useAuth();
const [form, setForm] = useState<CrmContactInput>(emptyCrmContactInput);
const [status, setStatus] = useState("Add account contacts for purchasing, AP, shipping, and engineering.");
const [isSaving, setIsSaving] = useState(false);
const canManage = user?.permissions.includes(permissions.crmWrite) ?? false;
function updateField<Key extends keyof CrmContactInput>(key: Key, value: CrmContactInput[Key]) {
setForm((current) => ({ ...current, [key]: value }));
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token) {
return;
}
setIsSaving(true);
setStatus("Saving contact...");
try {
const nextContact =
entity === "customer"
? await api.createCustomerContact(token, ownerId, form)
: await api.createVendorContact(token, ownerId, form);
onContactsChange(
[nextContact, ...contacts]
.sort((left, right) => Number(right.isPrimary) - Number(left.isPrimary) || left.fullName.localeCompare(right.fullName))
);
setForm({
...emptyCrmContactInput,
isPrimary: false,
});
setStatus("Contact added.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save CRM contact.";
setStatus(message);
} finally {
setIsSaving(false);
}
}
return (
<article className="min-w-0 rounded-[28px] border border-line/70 bg-surface/90 p-6 shadow-panel 2xl:p-7">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Contacts</p>
<h4 className="mt-3 text-xl font-bold text-text">People on this account</h4>
<div className="mt-5 space-y-3">
{contacts.length === 0 ? (
<div className="rounded-3xl border border-dashed border-line/70 bg-page/60 px-6 py-10 text-center text-sm text-muted">
No contacts have been added yet.
</div>
) : (
contacts.map((contact) => (
<div key={contact.id} className="rounded-3xl border border-line/70 bg-page/60 px-4 py-4">
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="text-sm font-semibold text-text">
{contact.fullName} {contact.isPrimary ? <span className="text-brand"> Primary</span> : null}
</div>
<div className="mt-1 text-sm text-muted">{crmContactRoleOptions.find((option) => option.value === contact.role)?.label ?? contact.role}</div>
</div>
<div className="text-sm text-muted lg:text-right">
<div>{contact.email}</div>
<div>{contact.phone}</div>
</div>
</div>
</div>
))
)}
</div>
{canManage ? (
<form className="mt-5 space-y-4" onSubmit={handleSubmit}>
<div className="grid gap-4 xl:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Full name</span>
<input
value={form.fullName}
onChange={(event) => updateField("fullName", 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"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Role</span>
<select
value={form.role}
onChange={(event) => updateField("role", event.target.value as CrmContactInput["role"])}
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
>
{crmContactRoleOptions.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">Email</span>
<input
type="email"
value={form.email}
onChange={(event) => updateField("email", 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"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Phone</span>
<input
value={form.phone}
onChange={(event) => updateField("phone", 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"
/>
</label>
</div>
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-4 py-3">
<input
type="checkbox"
checked={form.isPrimary}
onChange={(event) => updateField("isPrimary", event.target.checked)}
/>
<span className="text-sm font-semibold text-text">Primary contact</span>
</label>
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<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 contact"}
</button>
</div>
</form>
) : null}
</article>
);
}

View File

@@ -1,11 +1,12 @@
import { permissions } from "@mrp/shared";
import type { CrmContactEntryInput, CrmRecordDetailDto } from "@mrp/shared/dist/crm/types.js";
import type { CrmContactDto, 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 { CrmAttachmentsPanel } from "./CrmAttachmentsPanel";
import { CrmContactsPanel } from "./CrmContactsPanel";
import { CrmContactEntryForm } from "./CrmContactEntryForm";
import { CrmContactTypeBadge } from "./CrmContactTypeBadge";
import { CrmStatusBadge } from "./CrmStatusBadge";
@@ -145,6 +146,15 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
.join("\n")}
</dd>
</div>
<div className="md:col-span-2">
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Commercial terms</dt>
<dd className="mt-2 grid gap-3 text-sm text-text md:grid-cols-2">
<div>Payment terms: {record.paymentTerms ?? "Not set"}</div>
<div>Currency: {record.currencyCode ?? "USD"}</div>
<div>Tax exempt: {record.taxExempt ? "Yes" : "No"}</div>
<div>Credit hold: {record.creditHold ? "Yes" : "No"}</div>
</dd>
</div>
</dl>
</article>
<article className="min-w-0 rounded-[28px] border border-line/70 bg-surface/90 p-6 shadow-panel 2xl:p-7">
@@ -155,8 +165,56 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
<div className="mt-8 rounded-2xl border border-line/70 bg-page/70 px-4 py-4 text-sm text-muted">
Created {new Date(record.createdAt).toLocaleDateString()}
</div>
{entity === "customer" ? (
<div className="mt-4 rounded-2xl border border-line/70 bg-page/70 px-4 py-4">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Reseller Profile</p>
<div className="mt-3 grid gap-3 text-sm text-text">
<div>
<span className="font-semibold">Account type:</span>{" "}
{record.isReseller ? "Reseller" : record.parentCustomerName ? "End customer" : "Direct customer"}
</div>
<div>
<span className="font-semibold">Discount:</span> {(record.resellerDiscountPercent ?? 0).toFixed(2)}%
</div>
<div>
<span className="font-semibold">Parent reseller:</span> {record.parentCustomerName ?? "None"}
</div>
<div>
<span className="font-semibold">Child accounts:</span> {record.childCustomers?.length ?? 0}
</div>
</div>
</div>
) : null}
</article>
</div>
{entity === "customer" && (record.childCustomers?.length ?? 0) > 0 ? (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-6 shadow-panel 2xl:p-7">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Hierarchy</p>
<h4 className="mt-3 text-xl font-bold text-text">End customers under this reseller</h4>
<div className="mt-5 grid gap-3 xl:grid-cols-2 2xl:grid-cols-3">
{record.childCustomers?.map((child) => (
<Link
key={child.id}
to={`/crm/customers/${child.id}`}
className="rounded-3xl border border-line/70 bg-page/60 px-4 py-4 transition hover:border-brand/50 hover:bg-page/80"
>
<div className="text-sm font-semibold text-text">{child.name}</div>
<div className="mt-2">
<CrmStatusBadge status={child.status} />
</div>
</Link>
))}
</div>
</section>
) : null}
<CrmContactsPanel
entity={entity}
ownerId={record.id}
contacts={record.contacts ?? []}
onContactsChange={(contacts: CrmContactDto[]) =>
setRecord((current) => (current ? { ...current, contacts } : current))
}
/>
<section className="grid gap-4 2xl:grid-cols-[minmax(360px,0.88fr)_minmax(0,1.12fr)]">
{canManage ? (
<article className="min-w-0 rounded-[28px] border border-line/70 bg-surface/90 p-6 shadow-panel 2xl:p-7">

View File

@@ -1,4 +1,4 @@
import type { CrmRecordInput } from "@mrp/shared/dist/crm/types.js";
import type { CrmCustomerHierarchyOptionDto, CrmRecordInput } from "@mrp/shared/dist/crm/types.js";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
@@ -19,11 +19,23 @@ export function CrmFormPage({ entity, mode }: CrmFormPageProps) {
const recordId = entity === "customer" ? customerId : vendorId;
const config = crmConfigs[entity];
const [form, setForm] = useState<CrmRecordInput>(emptyCrmRecordInput);
const [hierarchyOptions, setHierarchyOptions] = useState<CrmCustomerHierarchyOptionDto[]>([]);
const [status, setStatus] = useState(
mode === "create" ? `Create a new ${config.singularLabel.toLowerCase()} record.` : `Loading ${config.singularLabel.toLowerCase()}...`
);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (entity !== "customer" || !token) {
return;
}
api
.getCustomerHierarchyOptions(token, mode === "edit" ? recordId : undefined)
.then(setHierarchyOptions)
.catch(() => setHierarchyOptions([]));
}, [entity, mode, recordId, token]);
useEffect(() => {
if (mode !== "edit" || !token || !recordId) {
return;
@@ -44,6 +56,13 @@ export function CrmFormPage({ entity, mode }: CrmFormPageProps) {
postalCode: record.postalCode,
country: record.country,
status: record.status,
isReseller: record.isReseller ?? false,
resellerDiscountPercent: record.resellerDiscountPercent ?? 0,
parentCustomerId: record.parentCustomerId ?? null,
paymentTerms: record.paymentTerms ?? "Net 30",
currencyCode: record.currencyCode ?? "USD",
taxExempt: record.taxExempt ?? false,
creditHold: record.creditHold ?? false,
notes: record.notes,
});
setStatus(`${config.singularLabel} record loaded.`);
@@ -107,7 +126,7 @@ export function CrmFormPage({ entity, mode }: CrmFormPageProps) {
</div>
</section>
<section className="space-y-5 rounded-[28px] border border-line/70 bg-surface/90 p-6 shadow-panel 2xl:p-7">
<CrmRecordForm form={form} onChange={updateField} />
<CrmRecordForm entity={entity} form={form} hierarchyOptions={hierarchyOptions} onChange={updateField} />
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<span className="min-w-0 text-sm text-muted">{status}</span>
<button

View File

@@ -125,6 +125,12 @@ export function CrmListPage({ entity }: CrmListPageProps) {
<Link to={`${config.routeBase}/${record.id}`} className="hover:text-brand">
{record.name}
</Link>
{entity === "customer" && (record.isReseller || record.parentCustomerName) ? (
<div className="mt-1 flex flex-wrap gap-2 text-xs font-medium text-muted">
{record.isReseller ? <span>Reseller</span> : null}
{record.parentCustomerName ? <span>Child of {record.parentCustomerName}</span> : null}
</div>
) : null}
</td>
<td className="px-4 py-3">
<CrmStatusBadge status={record.status} />

View File

@@ -1,8 +1,13 @@
import type { CrmRecordInput } from "@mrp/shared/dist/crm/types.js";
import type { CrmCustomerHierarchyOptionDto, CrmRecordInput } from "@mrp/shared/dist/crm/types.js";
import { crmStatusOptions } from "./config";
import type { CrmEntity } from "./config";
const fields: Array<{ key: keyof CrmRecordInput; label: string; type?: string }> = [
const fields: Array<{
key: "name" | "email" | "phone" | "addressLine1" | "addressLine2" | "city" | "state" | "postalCode" | "country";
label: string;
type?: string;
}> = [
{ key: "name", label: "Company name" },
{ key: "email", label: "Email", type: "email" },
{ key: "phone", label: "Phone" },
@@ -15,11 +20,13 @@ const fields: Array<{ key: keyof CrmRecordInput; label: string; type?: string }>
];
interface CrmRecordFormProps {
entity: CrmEntity;
form: CrmRecordInput;
hierarchyOptions?: CrmCustomerHierarchyOptionDto[];
onChange: <Key extends keyof CrmRecordInput>(key: Key, value: CrmRecordInput[Key]) => void;
}
export function CrmRecordForm({ form, onChange }: CrmRecordFormProps) {
export function CrmRecordForm({ entity, form, hierarchyOptions = [], onChange }: CrmRecordFormProps) {
return (
<>
<label className="block">
@@ -36,6 +43,87 @@ export function CrmRecordForm({ form, onChange }: CrmRecordFormProps) {
))}
</select>
</label>
{entity === "customer" ? (
<div className="grid gap-4 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)_minmax(0,1fr)]">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Reseller account</span>
<select
value={form.isReseller ? "yes" : "no"}
onChange={(event) => onChange("isReseller", event.target.value === "yes")}
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
>
<option value="no">Standard customer</option>
<option value="yes">Reseller</option>
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Reseller discount %</span>
<input
type="number"
min={0}
max={100}
step={0.01}
value={form.resellerDiscountPercent ?? 0}
disabled={!form.isReseller}
onChange={(event) =>
onChange("resellerDiscountPercent", event.target.value === "" ? 0 : Number(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 disabled:cursor-not-allowed disabled:opacity-60"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Parent reseller</span>
<select
value={form.parentCustomerId ?? ""}
onChange={(event) => onChange("parentCustomerId", event.target.value || null)}
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
>
<option value="">No parent reseller</option>
{hierarchyOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.name} {option.isReseller ? "(Reseller)" : ""}
</option>
))}
</select>
</label>
</div>
) : null}
<div className="grid gap-4 xl:grid-cols-4">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Payment terms</span>
<input
value={form.paymentTerms ?? ""}
onChange={(event) => onChange("paymentTerms", event.target.value || null)}
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
placeholder="Net 30"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Currency</span>
<input
value={form.currencyCode ?? "USD"}
onChange={(event) => onChange("currencyCode", event.target.value.toUpperCase() || null)}
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
maxLength={8}
/>
</label>
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-4 py-3">
<input
type="checkbox"
checked={form.taxExempt ?? false}
onChange={(event) => onChange("taxExempt", event.target.checked)}
/>
<span className="text-sm font-semibold text-text">Tax exempt</span>
</label>
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-4 py-3">
<input
type="checkbox"
checked={form.creditHold ?? false}
onChange={(event) => onChange("creditHold", event.target.checked)}
/>
<span className="text-sm font-semibold text-text">Credit hold</span>
</label>
</div>
<div className="grid gap-4 xl:grid-cols-2 2xl:grid-cols-3">
{fields.map((field) => (
<label key={String(field.key)} className="block">

View File

@@ -1,6 +1,9 @@
import {
crmContactRoles,
crmContactEntryTypes,
crmRecordStatuses,
type CrmContactInput,
type CrmContactRole,
type CrmContactEntryInput,
type CrmContactEntryType,
type CrmRecordInput,
@@ -49,6 +52,13 @@ export const emptyCrmRecordInput: CrmRecordInput = {
country: "USA",
status: "ACTIVE",
notes: "",
isReseller: false,
resellerDiscountPercent: 0,
parentCustomerId: null,
paymentTerms: "Net 30",
currencyCode: "USD",
taxExempt: false,
creditHold: false,
};
export const crmStatusOptions: Array<{ value: CrmRecordStatus; label: string }> = [
@@ -77,6 +87,14 @@ export const emptyCrmContactEntryInput: CrmContactEntryInput = {
contactAt: new Date().toISOString(),
};
export const emptyCrmContactInput: CrmContactInput = {
fullName: "",
role: "PRIMARY",
email: "",
phone: "",
isPrimary: true,
};
export const crmContactTypeOptions: Array<{ value: CrmContactEntryType; label: string }> = [
{ value: "NOTE", label: "Note" },
{ value: "CALL", label: "Call" },
@@ -91,4 +109,14 @@ export const crmContactTypePalette: Record<CrmContactEntryType, string> = {
MEETING: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
};
export { crmContactEntryTypes, crmRecordStatuses };
export const crmContactRoleOptions: Array<{ value: CrmContactRole; label: string }> = [
{ value: "PRIMARY", label: "Primary" },
{ value: "PURCHASING", label: "Purchasing" },
{ value: "AP", label: "Accounts Payable" },
{ value: "SHIPPING", label: "Shipping" },
{ value: "ENGINEERING", label: "Engineering" },
{ value: "SALES", label: "Sales" },
{ value: "OTHER", label: "Other" },
];
export { crmContactEntryTypes, crmContactRoles, crmRecordStatuses };