crm4
This commit is contained in:
45
README.md
45
README.md
@@ -2,13 +2,22 @@
|
||||
|
||||
Foundation release for a modular Manufacturing Resource Planning platform built with React, Express, Prisma, SQLite, and a single-container Docker deployment.
|
||||
|
||||
Current foundation scope includes:
|
||||
|
||||
- authentication and RBAC
|
||||
- company branding and theme settings
|
||||
- CRM customers and vendors with create/edit/detail workflows
|
||||
- CRM search, filtering, status tagging, and reseller hierarchy
|
||||
- CRM contact history, account contacts, and shared attachments
|
||||
- file storage and PDF rendering
|
||||
|
||||
## Workspace
|
||||
|
||||
- `client`: React, Vite, Tailwind frontend
|
||||
- `server`: Express API, Prisma, auth/RBAC, file storage, PDF rendering
|
||||
- `shared`: shared TypeScript contracts and constants
|
||||
|
||||
## Local development
|
||||
## Local Development
|
||||
|
||||
1. Use Node.js 22 for local development if you want Prisma migration commands to behave the same way as Docker.
|
||||
2. Install dependencies with `npm.cmd install`.
|
||||
@@ -45,22 +54,52 @@ docker build --build-arg NODE_VERSION=22 -t mrp-codex .
|
||||
|
||||
The container startup script runs `npx prisma migrate deploy` automatically before launching the server.
|
||||
|
||||
## Persistence and backup
|
||||
This Docker path is currently the most reliable way to ensure the database schema matches the latest CRM migrations on Windows.
|
||||
|
||||
## Persistence And Backup
|
||||
|
||||
- SQLite database path: `/app/data/prisma/app.db`
|
||||
- Uploaded files: `/app/data/uploads`
|
||||
- Backup the entire mounted `/app/data` volume to preserve both records and attachments.
|
||||
|
||||
## CRM
|
||||
|
||||
The current CRM foundation supports:
|
||||
|
||||
- customer and vendor list, detail, create, and edit flows
|
||||
- search by text plus status and state/province filters
|
||||
- customer reseller flag, reseller discount, and parent-child hierarchy
|
||||
- contact-history timeline entries for notes, calls, emails, and meetings
|
||||
- multiple account contacts with role and primary-contact tracking
|
||||
- shared file attachments on customer and vendor records
|
||||
- commercial terms fields including payment terms, currency, tax exempt, and credit hold
|
||||
|
||||
Recent CRM features depend on the committed Prisma migrations being applied. If you update the code and do not run migrations, the UI may render fields that are not yet present in the database.
|
||||
|
||||
## Branding
|
||||
|
||||
Brand colors and typography are configured through the Company Settings page and the frontend theme token layer. Update runtime branding in-app, or adjust defaults in the theme config if you need a new baseline brand.
|
||||
|
||||
Logo uploads are stored through the authenticated file pipeline and are rendered back into the settings UI through an authenticated blob fetch, so image preview works after save and refresh.
|
||||
|
||||
## Migrations
|
||||
|
||||
- Create a local migration: `npm run prisma:migrate`
|
||||
- Apply committed migrations in production: `npm run prisma:deploy`
|
||||
- If Prisma migration commands fail on a local Node 24 Windows environment, use Node 22 or Docker for migration execution. The committed migration files in `server/prisma/migrations` remain the source of truth.
|
||||
|
||||
## PDF generation
|
||||
As of March 14, 2026, the latest committed CRM migrations include:
|
||||
|
||||
- CRM status and list filters
|
||||
- CRM contact-history timeline
|
||||
- reseller hierarchy and reseller discount support
|
||||
- CRM commercial terms and account contacts
|
||||
|
||||
## UI Notes
|
||||
|
||||
- Dark mode persistence is handled through the frontend theme provider and should remain stable across page navigation.
|
||||
- The shell layout is tuned for wider desktop use than the original foundation build, but the client build still emits a Vite chunk-size warning because the app has not been code-split yet.
|
||||
|
||||
## PDF Generation
|
||||
|
||||
Puppeteer is used by the backend to render HTML templates into professional PDFs. The Docker image includes Chromium runtime dependencies required for headless execution.
|
||||
|
||||
@@ -23,6 +23,7 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
|
||||
- CRM search, filters, and persisted status tagging
|
||||
- CRM contact-history timeline with authored notes, calls, emails, and meetings
|
||||
- CRM shared file attachments on customer and vendor records
|
||||
- CRM reseller hierarchy, parent-child customer structure, and reseller discount support
|
||||
- Theme persistence fixes and denser responsive workspace layouts
|
||||
- SVAR Gantt integration wrapper with demo planning data
|
||||
- Multi-stage Docker packaging and migration-aware entrypoint
|
||||
@@ -33,7 +34,7 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
|
||||
|
||||
- Prisma migration execution is committed and documented, but local Windows Node 24 schema-engine behavior remains inconsistent; use Node 22 or Docker for migration execution
|
||||
- The frontend bundle is functional but should be code-split later, especially around the gantt module
|
||||
- CRM deeper operational metadata and lifecycle reporting are not built yet
|
||||
- CRM deeper operational metadata, lifecycle reporting, and richer multi-contact/commercial terms are not built yet
|
||||
|
||||
## Planned feature phases
|
||||
|
||||
@@ -41,6 +42,7 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
|
||||
|
||||
- Better seed/bootstrap strategy for non-development environments
|
||||
- Deeper CRM operational fields and lifecycle reporting
|
||||
- Multi-contact records, commercial terms, and account-role expansion
|
||||
|
||||
### Phase 2: Inventory and manufacturing core
|
||||
|
||||
@@ -94,7 +96,7 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
|
||||
|
||||
## Near-term priority order
|
||||
|
||||
1. CRM deeper operational fields and lifecycle reporting
|
||||
1. CRM deeper operational fields, commercial terms, and lifecycle reporting
|
||||
2. Inventory item and BOM data model
|
||||
3. Sales order and quote foundation
|
||||
4. Shipping module tied to sales orders
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
153
client/src/modules/crm/CrmContactsPanel.tsx
Normal file
153
client/src/modules/crm/CrmContactsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE "Customer" ADD COLUMN "isReseller" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE "Customer" ADD COLUMN "resellerDiscountPercent" REAL NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE "Customer" ADD COLUMN "parentCustomerId" TEXT REFERENCES "Customer" ("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX "Customer_parentCustomerId_idx" ON "Customer"("parentCustomerId");
|
||||
@@ -0,0 +1,27 @@
|
||||
ALTER TABLE "Customer" ADD COLUMN "paymentTerms" TEXT;
|
||||
ALTER TABLE "Customer" ADD COLUMN "currencyCode" TEXT DEFAULT 'USD';
|
||||
ALTER TABLE "Customer" ADD COLUMN "taxExempt" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Customer" ADD COLUMN "creditHold" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE "Vendor" ADD COLUMN "paymentTerms" TEXT;
|
||||
ALTER TABLE "Vendor" ADD COLUMN "currencyCode" TEXT DEFAULT 'USD';
|
||||
ALTER TABLE "Vendor" ADD COLUMN "taxExempt" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Vendor" ADD COLUMN "creditHold" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE TABLE "CrmContact" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"fullName" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'OTHER',
|
||||
"email" TEXT NOT NULL,
|
||||
"phone" TEXT NOT NULL,
|
||||
"isPrimary" BOOLEAN NOT NULL DEFAULT false,
|
||||
"customerId" TEXT,
|
||||
"vendorId" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "CrmContact_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "CrmContact_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX "CrmContact_customerId_idx" ON "CrmContact"("customerId");
|
||||
CREATE INDEX "CrmContact_vendorId_idx" ON "CrmContact"("vendorId");
|
||||
@@ -113,10 +113,20 @@ model Customer {
|
||||
postalCode String
|
||||
country String
|
||||
status String @default("ACTIVE")
|
||||
isReseller Boolean @default(false)
|
||||
resellerDiscountPercent Float @default(0)
|
||||
parentCustomerId String?
|
||||
paymentTerms String?
|
||||
currencyCode String? @default("USD")
|
||||
taxExempt Boolean @default(false)
|
||||
creditHold Boolean @default(false)
|
||||
notes String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
contactEntries CrmContactEntry[]
|
||||
contacts CrmContact[]
|
||||
parentCustomer Customer? @relation("CustomerHierarchy", fields: [parentCustomerId], references: [id], onDelete: SetNull)
|
||||
childCustomers Customer[] @relation("CustomerHierarchy")
|
||||
}
|
||||
|
||||
model Vendor {
|
||||
@@ -131,10 +141,15 @@ model Vendor {
|
||||
postalCode String
|
||||
country String
|
||||
status String @default("ACTIVE")
|
||||
paymentTerms String?
|
||||
currencyCode String? @default("USD")
|
||||
taxExempt Boolean @default(false)
|
||||
creditHold Boolean @default(false)
|
||||
notes String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
contactEntries CrmContactEntry[]
|
||||
contacts CrmContact[]
|
||||
}
|
||||
|
||||
model CrmContactEntry {
|
||||
@@ -152,3 +167,18 @@ model CrmContactEntry {
|
||||
vendor Vendor? @relation(fields: [vendorId], references: [id], onDelete: Cascade)
|
||||
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
|
||||
}
|
||||
|
||||
model CrmContact {
|
||||
id String @id @default(cuid())
|
||||
fullName String
|
||||
role String @default("OTHER")
|
||||
email String
|
||||
phone String
|
||||
isPrimary Boolean @default(false)
|
||||
customerId String?
|
||||
vendorId 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)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import { crmContactEntryTypes, crmRecordStatuses } from "@mrp/shared/dist/crm/types.js";
|
||||
import { crmContactEntryTypes, crmContactRoles, crmRecordStatuses } from "@mrp/shared/dist/crm/types.js";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -7,12 +7,15 @@ import { fail, ok } from "../../lib/http.js";
|
||||
import { requirePermissions } from "../../lib/rbac.js";
|
||||
import {
|
||||
createCustomerContactEntry,
|
||||
createCustomerContact,
|
||||
createCustomer,
|
||||
createVendorContactEntry,
|
||||
createVendorContact,
|
||||
createVendor,
|
||||
getCustomerById,
|
||||
getVendorById,
|
||||
listCustomers,
|
||||
listCustomerHierarchyOptions,
|
||||
listVendors,
|
||||
updateCustomer,
|
||||
updateVendor,
|
||||
@@ -30,6 +33,13 @@ const crmRecordSchema = z.object({
|
||||
country: z.string().trim().min(1),
|
||||
status: z.enum(crmRecordStatuses),
|
||||
notes: z.string(),
|
||||
isReseller: z.boolean().optional(),
|
||||
resellerDiscountPercent: z.number().min(0).max(100).nullable().optional(),
|
||||
parentCustomerId: z.string().nullable().optional(),
|
||||
paymentTerms: z.string().nullable().optional(),
|
||||
currencyCode: z.string().max(8).nullable().optional(),
|
||||
taxExempt: z.boolean().optional(),
|
||||
creditHold: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const crmListQuerySchema = z.object({
|
||||
@@ -45,7 +55,15 @@ const crmContactEntrySchema = z.object({
|
||||
contactAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
function getRouteParam(value: string | string[] | undefined) {
|
||||
const crmContactSchema = z.object({
|
||||
fullName: z.string().trim().min(1).max(160),
|
||||
role: z.enum(crmContactRoles),
|
||||
email: z.string().trim().email(),
|
||||
phone: z.string().trim().min(1).max(64),
|
||||
isPrimary: z.boolean(),
|
||||
});
|
||||
|
||||
function getRouteParam(value: unknown) {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
@@ -67,6 +85,11 @@ crmRouter.get("/customers", requirePermissions([permissions.crmRead]), async (_r
|
||||
);
|
||||
});
|
||||
|
||||
crmRouter.get("/customers/hierarchy-options", requirePermissions([permissions.crmRead]), async (request, response) => {
|
||||
const excludeCustomerId = getRouteParam(request.query.excludeCustomerId);
|
||||
return ok(response, await listCustomerHierarchyOptions(excludeCustomerId ?? undefined));
|
||||
});
|
||||
|
||||
crmRouter.get("/customers/:customerId", requirePermissions([permissions.crmRead]), async (request, response) => {
|
||||
const customerId = getRouteParam(request.params.customerId);
|
||||
if (!customerId) {
|
||||
@@ -87,7 +110,12 @@ crmRouter.post("/customers", requirePermissions([permissions.crmWrite]), async (
|
||||
return fail(response, 400, "INVALID_INPUT", "Customer payload is invalid.");
|
||||
}
|
||||
|
||||
return ok(response, await createCustomer(parsed.data), 201);
|
||||
const customer = await createCustomer(parsed.data);
|
||||
if (!customer) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Customer reseller relationship is invalid.");
|
||||
}
|
||||
|
||||
return ok(response, customer, 201);
|
||||
});
|
||||
|
||||
crmRouter.put("/customers/:customerId", requirePermissions([permissions.crmWrite]), async (request, response) => {
|
||||
@@ -101,9 +129,14 @@ crmRouter.put("/customers/:customerId", requirePermissions([permissions.crmWrite
|
||||
return fail(response, 400, "INVALID_INPUT", "Customer payload is invalid.");
|
||||
}
|
||||
|
||||
const existingCustomer = await getCustomerById(customerId);
|
||||
if (!existingCustomer) {
|
||||
return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found.");
|
||||
}
|
||||
|
||||
const customer = await updateCustomer(customerId, parsed.data);
|
||||
if (!customer) {
|
||||
return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found.");
|
||||
return fail(response, 400, "INVALID_INPUT", "Customer reseller relationship is invalid.");
|
||||
}
|
||||
|
||||
return ok(response, customer);
|
||||
@@ -128,6 +161,25 @@ crmRouter.post("/customers/:customerId/contact-history", requirePermissions([per
|
||||
return ok(response, entry, 201);
|
||||
});
|
||||
|
||||
crmRouter.post("/customers/:customerId/contacts", 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 = crmContactSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "CRM contact is invalid.");
|
||||
}
|
||||
|
||||
const contact = await createCustomerContact(customerId, parsed.data);
|
||||
if (!contact) {
|
||||
return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found.");
|
||||
}
|
||||
|
||||
return ok(response, contact, 201);
|
||||
});
|
||||
|
||||
crmRouter.get("/vendors", requirePermissions([permissions.crmRead]), async (_request, response) => {
|
||||
const parsed = crmListQuerySchema.safeParse(_request.query);
|
||||
if (!parsed.success) {
|
||||
@@ -204,3 +256,22 @@ crmRouter.post("/vendors/:vendorId/contact-history", requirePermissions([permiss
|
||||
|
||||
return ok(response, entry, 201);
|
||||
});
|
||||
|
||||
crmRouter.post("/vendors/:vendorId/contacts", 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 = crmContactSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "CRM contact is invalid.");
|
||||
}
|
||||
|
||||
const contact = await createVendorContact(vendorId, parsed.data);
|
||||
if (!contact) {
|
||||
return fail(response, 404, "CRM_VENDOR_NOT_FOUND", "Vendor record was not found.");
|
||||
}
|
||||
|
||||
return ok(response, contact, 201);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type {
|
||||
CrmContactDto,
|
||||
CrmContactInput,
|
||||
CrmContactRole,
|
||||
CrmContactEntryDto,
|
||||
CrmContactEntryInput,
|
||||
CrmContactEntryType,
|
||||
CrmCustomerChildDto,
|
||||
CrmRecordDetailDto,
|
||||
CrmRecordInput,
|
||||
CrmRecordStatus,
|
||||
@@ -37,6 +41,88 @@ function mapDetail(record: Customer | Vendor): CrmRecordDetailDto {
|
||||
};
|
||||
}
|
||||
|
||||
type CustomerSummaryRecord = Customer & {
|
||||
parentCustomer: Pick<Customer, "id" | "name"> | null;
|
||||
};
|
||||
|
||||
type CustomerDetailedRecord = Customer & {
|
||||
parentCustomer: Pick<Customer, "id" | "name"> | null;
|
||||
childCustomers: Pick<Customer, "id" | "name" | "status">[];
|
||||
contactEntries: ContactEntryWithAuthor[];
|
||||
contacts: ContactRecord[];
|
||||
};
|
||||
|
||||
type VendorDetailedRecord = Vendor & {
|
||||
contactEntries: ContactEntryWithAuthor[];
|
||||
contacts: ContactRecord[];
|
||||
};
|
||||
|
||||
type ContactRecord = {
|
||||
id: string;
|
||||
fullName: string;
|
||||
role: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
isPrimary: boolean;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
function mapCustomerChild(record: Pick<Customer, "id" | "name" | "status">): CrmCustomerChildDto {
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
status: record.status as CrmRecordStatus,
|
||||
};
|
||||
}
|
||||
|
||||
function mapCustomerSummary(record: CustomerSummaryRecord): CrmRecordSummaryDto {
|
||||
return {
|
||||
...mapSummary(record),
|
||||
isReseller: record.isReseller,
|
||||
parentCustomerId: record.parentCustomer?.id ?? null,
|
||||
parentCustomerName: record.parentCustomer?.name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapCustomerDetail(record: CustomerDetailedRecord): CrmRecordDetailDto {
|
||||
return {
|
||||
...mapDetailedRecord(record),
|
||||
isReseller: record.isReseller,
|
||||
resellerDiscountPercent: record.resellerDiscountPercent,
|
||||
parentCustomerId: record.parentCustomer?.id ?? null,
|
||||
parentCustomerName: record.parentCustomer?.name ?? null,
|
||||
childCustomers: record.childCustomers.map(mapCustomerChild),
|
||||
paymentTerms: record.paymentTerms,
|
||||
currencyCode: record.currencyCode,
|
||||
taxExempt: record.taxExempt,
|
||||
creditHold: record.creditHold,
|
||||
contacts: record.contacts.map(mapCrmContact),
|
||||
};
|
||||
}
|
||||
|
||||
function mapCrmContact(record: ContactRecord): CrmContactDto {
|
||||
return {
|
||||
id: record.id,
|
||||
fullName: record.fullName,
|
||||
role: record.role as CrmContactRole,
|
||||
email: record.email,
|
||||
phone: record.phone,
|
||||
isPrimary: record.isPrimary,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function mapVendorDetail(record: VendorDetailedRecord): CrmRecordDetailDto {
|
||||
return {
|
||||
...mapDetailedRecord(record),
|
||||
paymentTerms: record.paymentTerms,
|
||||
currencyCode: record.currencyCode,
|
||||
taxExempt: record.taxExempt,
|
||||
creditHold: record.creditHold,
|
||||
contacts: record.contacts.map(mapCrmContact),
|
||||
};
|
||||
}
|
||||
|
||||
type ContactEntryWithAuthor = {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -120,16 +206,43 @@ function buildWhereClause(filters: CrmListFilters) {
|
||||
export async function listCustomers(filters: CrmListFilters = {}) {
|
||||
const customers = await prisma.customer.findMany({
|
||||
where: buildWhereClause(filters),
|
||||
include: {
|
||||
parentCustomer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return customers.map(mapSummary);
|
||||
return customers.map(mapCustomerSummary);
|
||||
}
|
||||
|
||||
export async function getCustomerById(customerId: string) {
|
||||
const customer = await prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
include: {
|
||||
parentCustomer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
childCustomers: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
},
|
||||
orderBy: {
|
||||
name: "asc",
|
||||
},
|
||||
},
|
||||
contacts: {
|
||||
orderBy: [{ isPrimary: "desc" }, { fullName: "asc" }],
|
||||
},
|
||||
contactEntries: {
|
||||
include: {
|
||||
createdBy: true,
|
||||
@@ -139,15 +252,56 @@ export async function getCustomerById(customerId: string) {
|
||||
},
|
||||
});
|
||||
|
||||
return customer ? mapDetailedRecord(customer) : null;
|
||||
return customer ? mapCustomerDetail(customer) : null;
|
||||
}
|
||||
|
||||
export async function createCustomer(payload: CrmRecordInput) {
|
||||
if (payload.parentCustomerId) {
|
||||
const parentCustomer = await prisma.customer.findUnique({
|
||||
where: { id: payload.parentCustomerId },
|
||||
});
|
||||
|
||||
if (!parentCustomer) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const customer = await prisma.customer.create({
|
||||
data: payload,
|
||||
data: {
|
||||
name: payload.name,
|
||||
email: payload.email,
|
||||
phone: payload.phone,
|
||||
addressLine1: payload.addressLine1,
|
||||
addressLine2: payload.addressLine2,
|
||||
city: payload.city,
|
||||
state: payload.state,
|
||||
postalCode: payload.postalCode,
|
||||
country: payload.country,
|
||||
status: payload.status,
|
||||
notes: payload.notes,
|
||||
isReseller: payload.isReseller ?? false,
|
||||
resellerDiscountPercent: payload.resellerDiscountPercent ?? 0,
|
||||
parentCustomerId: payload.parentCustomerId ?? null,
|
||||
paymentTerms: payload.paymentTerms ?? null,
|
||||
currencyCode: payload.currencyCode ?? "USD",
|
||||
taxExempt: payload.taxExempt ?? false,
|
||||
creditHold: payload.creditHold ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
return mapDetail(customer);
|
||||
return {
|
||||
...mapDetail(customer),
|
||||
isReseller: customer.isReseller,
|
||||
resellerDiscountPercent: customer.resellerDiscountPercent,
|
||||
parentCustomerId: customer.parentCustomerId,
|
||||
parentCustomerName: null,
|
||||
childCustomers: [],
|
||||
paymentTerms: customer.paymentTerms,
|
||||
currencyCode: customer.currencyCode,
|
||||
taxExempt: customer.taxExempt,
|
||||
creditHold: customer.creditHold,
|
||||
contacts: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateCustomer(customerId: string, payload: CrmRecordInput) {
|
||||
@@ -159,12 +313,57 @@ export async function updateCustomer(customerId: string, payload: CrmRecordInput
|
||||
return null;
|
||||
}
|
||||
|
||||
if (payload.parentCustomerId === customerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (payload.parentCustomerId) {
|
||||
const parentCustomer = await prisma.customer.findUnique({
|
||||
where: { id: payload.parentCustomerId },
|
||||
});
|
||||
|
||||
if (!parentCustomer) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const customer = await prisma.customer.update({
|
||||
where: { id: customerId },
|
||||
data: payload,
|
||||
data: {
|
||||
name: payload.name,
|
||||
email: payload.email,
|
||||
phone: payload.phone,
|
||||
addressLine1: payload.addressLine1,
|
||||
addressLine2: payload.addressLine2,
|
||||
city: payload.city,
|
||||
state: payload.state,
|
||||
postalCode: payload.postalCode,
|
||||
country: payload.country,
|
||||
status: payload.status,
|
||||
notes: payload.notes,
|
||||
isReseller: payload.isReseller ?? false,
|
||||
resellerDiscountPercent: payload.resellerDiscountPercent ?? 0,
|
||||
parentCustomerId: payload.parentCustomerId ?? null,
|
||||
paymentTerms: payload.paymentTerms ?? null,
|
||||
currencyCode: payload.currencyCode ?? "USD",
|
||||
taxExempt: payload.taxExempt ?? false,
|
||||
creditHold: payload.creditHold ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
return mapDetail(customer);
|
||||
return {
|
||||
...mapDetail(customer),
|
||||
isReseller: customer.isReseller,
|
||||
resellerDiscountPercent: customer.resellerDiscountPercent,
|
||||
parentCustomerId: customer.parentCustomerId,
|
||||
parentCustomerName: null,
|
||||
childCustomers: [],
|
||||
paymentTerms: customer.paymentTerms,
|
||||
currencyCode: customer.currencyCode,
|
||||
taxExempt: customer.taxExempt,
|
||||
creditHold: customer.creditHold,
|
||||
contacts: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function listVendors(filters: CrmListFilters = {}) {
|
||||
@@ -176,10 +375,44 @@ export async function listVendors(filters: CrmListFilters = {}) {
|
||||
return vendors.map(mapSummary);
|
||||
}
|
||||
|
||||
export async function listCustomerHierarchyOptions(excludeCustomerId?: string) {
|
||||
const customers = await prisma.customer.findMany({
|
||||
where: excludeCustomerId
|
||||
? {
|
||||
isReseller: true,
|
||||
id: {
|
||||
not: excludeCustomerId,
|
||||
},
|
||||
}
|
||||
: {
|
||||
isReseller: true,
|
||||
},
|
||||
orderBy: {
|
||||
name: "asc",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
isReseller: true,
|
||||
},
|
||||
});
|
||||
|
||||
return customers.map((customer) => ({
|
||||
id: customer.id,
|
||||
name: customer.name,
|
||||
status: customer.status as CrmRecordStatus,
|
||||
isReseller: customer.isReseller,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getVendorById(vendorId: string) {
|
||||
const vendor = await prisma.vendor.findUnique({
|
||||
where: { id: vendorId },
|
||||
include: {
|
||||
contacts: {
|
||||
orderBy: [{ isPrimary: "desc" }, { fullName: "asc" }],
|
||||
},
|
||||
contactEntries: {
|
||||
include: {
|
||||
createdBy: true,
|
||||
@@ -189,15 +422,38 @@ export async function getVendorById(vendorId: string) {
|
||||
},
|
||||
});
|
||||
|
||||
return vendor ? mapDetailedRecord(vendor) : null;
|
||||
return vendor ? mapVendorDetail(vendor) : null;
|
||||
}
|
||||
|
||||
export async function createVendor(payload: CrmRecordInput) {
|
||||
const vendor = await prisma.vendor.create({
|
||||
data: payload,
|
||||
data: {
|
||||
name: payload.name,
|
||||
email: payload.email,
|
||||
phone: payload.phone,
|
||||
addressLine1: payload.addressLine1,
|
||||
addressLine2: payload.addressLine2,
|
||||
city: payload.city,
|
||||
state: payload.state,
|
||||
postalCode: payload.postalCode,
|
||||
country: payload.country,
|
||||
status: payload.status,
|
||||
notes: payload.notes,
|
||||
paymentTerms: payload.paymentTerms ?? null,
|
||||
currencyCode: payload.currencyCode ?? "USD",
|
||||
taxExempt: payload.taxExempt ?? false,
|
||||
creditHold: payload.creditHold ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
return mapDetail(vendor);
|
||||
return {
|
||||
...mapDetail(vendor),
|
||||
paymentTerms: vendor.paymentTerms,
|
||||
currencyCode: vendor.currencyCode,
|
||||
taxExempt: vendor.taxExempt,
|
||||
creditHold: vendor.creditHold,
|
||||
contacts: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateVendor(vendorId: string, payload: CrmRecordInput) {
|
||||
@@ -211,10 +467,33 @@ export async function updateVendor(vendorId: string, payload: CrmRecordInput) {
|
||||
|
||||
const vendor = await prisma.vendor.update({
|
||||
where: { id: vendorId },
|
||||
data: payload,
|
||||
data: {
|
||||
name: payload.name,
|
||||
email: payload.email,
|
||||
phone: payload.phone,
|
||||
addressLine1: payload.addressLine1,
|
||||
addressLine2: payload.addressLine2,
|
||||
city: payload.city,
|
||||
state: payload.state,
|
||||
postalCode: payload.postalCode,
|
||||
country: payload.country,
|
||||
status: payload.status,
|
||||
notes: payload.notes,
|
||||
paymentTerms: payload.paymentTerms ?? null,
|
||||
currencyCode: payload.currencyCode ?? "USD",
|
||||
taxExempt: payload.taxExempt ?? false,
|
||||
creditHold: payload.creditHold ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
return mapDetail(vendor);
|
||||
return {
|
||||
...mapDetail(vendor),
|
||||
paymentTerms: vendor.paymentTerms,
|
||||
currencyCode: vendor.currencyCode,
|
||||
taxExempt: vendor.taxExempt,
|
||||
creditHold: vendor.creditHold,
|
||||
contacts: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function createCustomerContactEntry(customerId: string, payload: CrmContactEntryInput, createdById?: string) {
|
||||
@@ -268,3 +547,63 @@ export async function createVendorContactEntry(vendorId: string, payload: CrmCon
|
||||
|
||||
return mapContactEntry(entry);
|
||||
}
|
||||
|
||||
export async function createCustomerContact(customerId: string, payload: CrmContactInput) {
|
||||
const existingCustomer = await prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
});
|
||||
|
||||
if (!existingCustomer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (payload.isPrimary) {
|
||||
await prisma.crmContact.updateMany({
|
||||
where: { customerId, isPrimary: true },
|
||||
data: { isPrimary: false },
|
||||
});
|
||||
}
|
||||
|
||||
const contact = await prisma.crmContact.create({
|
||||
data: {
|
||||
fullName: payload.fullName,
|
||||
role: payload.role,
|
||||
email: payload.email,
|
||||
phone: payload.phone,
|
||||
isPrimary: payload.isPrimary,
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
return mapCrmContact(contact);
|
||||
}
|
||||
|
||||
export async function createVendorContact(vendorId: string, payload: CrmContactInput) {
|
||||
const existingVendor = await prisma.vendor.findUnique({
|
||||
where: { id: vendorId },
|
||||
});
|
||||
|
||||
if (!existingVendor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (payload.isPrimary) {
|
||||
await prisma.crmContact.updateMany({
|
||||
where: { vendorId, isPrimary: true },
|
||||
data: { isPrimary: false },
|
||||
});
|
||||
}
|
||||
|
||||
const contact = await prisma.crmContact.create({
|
||||
data: {
|
||||
fullName: payload.fullName,
|
||||
role: payload.role,
|
||||
email: payload.email,
|
||||
phone: payload.phone,
|
||||
isPrimary: payload.isPrimary,
|
||||
vendorId,
|
||||
},
|
||||
});
|
||||
|
||||
return mapCrmContact(contact);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ export const crmContactEntryTypes = ["NOTE", "CALL", "EMAIL", "MEETING"] as cons
|
||||
|
||||
export type CrmRecordStatus = (typeof crmRecordStatuses)[number];
|
||||
export type CrmContactEntryType = (typeof crmContactEntryTypes)[number];
|
||||
export const crmContactRoles = ["PRIMARY", "PURCHASING", "AP", "SHIPPING", "ENGINEERING", "SALES", "OTHER"] as const;
|
||||
export type CrmContactRole = (typeof crmContactRoles)[number];
|
||||
|
||||
export interface CrmContactEntryDto {
|
||||
id: string;
|
||||
@@ -25,6 +27,37 @@ export interface CrmContactEntryInput {
|
||||
contactAt: string;
|
||||
}
|
||||
|
||||
export interface CrmContactDto {
|
||||
id: string;
|
||||
fullName: string;
|
||||
role: CrmContactRole;
|
||||
email: string;
|
||||
phone: string;
|
||||
isPrimary: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CrmContactInput {
|
||||
fullName: string;
|
||||
role: CrmContactRole;
|
||||
email: string;
|
||||
phone: string;
|
||||
isPrimary: boolean;
|
||||
}
|
||||
|
||||
export interface CrmCustomerChildDto {
|
||||
id: string;
|
||||
name: string;
|
||||
status: CrmRecordStatus;
|
||||
}
|
||||
|
||||
export interface CrmCustomerHierarchyOptionDto {
|
||||
id: string;
|
||||
name: string;
|
||||
status: CrmRecordStatus;
|
||||
isReseller: boolean;
|
||||
}
|
||||
|
||||
export interface CrmRecordSummaryDto {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -34,6 +67,9 @@ export interface CrmRecordSummaryDto {
|
||||
state: string;
|
||||
country: string;
|
||||
status: CrmRecordStatus;
|
||||
isReseller?: boolean;
|
||||
parentCustomerId?: string | null;
|
||||
parentCustomerName?: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -44,6 +80,16 @@ export interface CrmRecordDetailDto extends CrmRecordSummaryDto {
|
||||
notes: string;
|
||||
createdAt: string;
|
||||
contactHistory: CrmContactEntryDto[];
|
||||
isReseller?: boolean;
|
||||
resellerDiscountPercent?: number | null;
|
||||
parentCustomerId?: string | null;
|
||||
parentCustomerName?: string | null;
|
||||
childCustomers?: CrmCustomerChildDto[];
|
||||
paymentTerms?: string | null;
|
||||
currencyCode?: string | null;
|
||||
taxExempt?: boolean;
|
||||
creditHold?: boolean;
|
||||
contacts?: CrmContactDto[];
|
||||
}
|
||||
|
||||
export interface CrmRecordInput {
|
||||
@@ -58,6 +104,13 @@ export interface CrmRecordInput {
|
||||
country: string;
|
||||
status: CrmRecordStatus;
|
||||
notes: string;
|
||||
isReseller?: boolean;
|
||||
resellerDiscountPercent?: number | null;
|
||||
parentCustomerId?: string | null;
|
||||
paymentTerms?: string | null;
|
||||
currencyCode?: string | null;
|
||||
taxExempt?: boolean;
|
||||
creditHold?: boolean;
|
||||
}
|
||||
|
||||
export type CustomerSummaryDto = CrmRecordSummaryDto;
|
||||
|
||||
Reference in New Issue
Block a user