This commit is contained in:
2026-03-14 16:08:29 -05:00
parent 84bd962744
commit 9c8298c5e3
17 changed files with 975 additions and 102 deletions

View File

@@ -19,26 +19,27 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
- Local file attachment storage under `/app/data/uploads`
- Puppeteer PDF service foundation with branded company-profile preview
- CRM reference entities for customers and vendors
- CRM customer and vendor create/edit/detail workflows
- CRM search, filters, and persisted status tagging
- SVAR Gantt integration wrapper with demo planning data
- Multi-stage Docker packaging and migration-aware entrypoint
- Docker image validated locally with successful app startup and login flow
- Core project documentation in `README.md`, `INSTRUCTIONS.md`, and `STRUCTURE.md`
### Current known gaps in the foundation
- Docker runtime has been authored but not validated in this environment because the local Docker daemon was unavailable
- 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 is currently read-focused and seeded; create/update/detail workflows still need to be built
- CRM contact history, shared attachments, and deeper operational metadata are not built yet
## Planned feature phases
### Phase 1: CRM and master data hardening
- Customer and vendor create/edit/detail pages
- Search, filters, and status tagging
- Contact history and internal notes
- Shared attachment support on CRM entities
- Better seed/bootstrap strategy for non-development environments
- Deeper CRM operational fields and lifecycle reporting
### Phase 2: Inventory and manufacturing core
@@ -91,9 +92,8 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
## Near-term priority order
1. CRM detail and edit workflows
2. Inventory item and BOM data model
3. Sales order and quote foundation
4. Shipping module tied to sales orders
5. Live manufacturing gantt scheduling
1. CRM contact history and internal notes
2. CRM shared attachments and operational metadata
3. Inventory item and BOM data model
4. Sales order and quote foundation
5. Shipping module tied to sales orders

View File

@@ -8,6 +8,12 @@ import type {
LoginRequest,
LoginResponse,
} from "@mrp/shared";
import type {
CrmRecordDetailDto,
CrmRecordInput,
CrmRecordStatus,
CrmRecordSummaryDto,
} from "@mrp/shared/dist/crm/types.js";
export class ApiError extends Error {
constructor(message: string, public readonly code: string) {
@@ -33,6 +39,18 @@ async function request<T>(input: string, init?: RequestInit, token?: string): Pr
return json.data;
}
function buildQueryString(params: Record<string, string | undefined>) {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value) {
searchParams.set(key, value);
}
}
const queryString = searchParams.toString();
return queryString ? `?${queryString}` : "";
}
export const api = {
login(payload: LoginRequest) {
return request<LoginResponse>("/api/v1/auth/login", {
@@ -75,11 +93,73 @@ export const api = {
}
return json.data;
},
getCustomers(token: string) {
return request<Array<Record<string, string>>>("/api/v1/crm/customers", undefined, token);
getCustomers(token: string, filters?: { q?: string; status?: CrmRecordStatus; state?: string }) {
return request<CrmRecordSummaryDto[]>(
`/api/v1/crm/customers${buildQueryString({
q: filters?.q,
status: filters?.status,
state: filters?.state,
})}`,
undefined,
token
);
},
getVendors(token: string) {
return request<Array<Record<string, string>>>("/api/v1/crm/vendors", undefined, token);
getCustomer(token: string, customerId: string) {
return request<CrmRecordDetailDto>(`/api/v1/crm/customers/${customerId}`, undefined, token);
},
createCustomer(token: string, payload: CrmRecordInput) {
return request<CrmRecordDetailDto>(
"/api/v1/crm/customers",
{
method: "POST",
body: JSON.stringify(payload),
},
token
);
},
updateCustomer(token: string, customerId: string, payload: CrmRecordInput) {
return request<CrmRecordDetailDto>(
`/api/v1/crm/customers/${customerId}`,
{
method: "PUT",
body: JSON.stringify(payload),
},
token
);
},
getVendors(token: string, filters?: { q?: string; status?: CrmRecordStatus; state?: string }) {
return request<CrmRecordSummaryDto[]>(
`/api/v1/crm/vendors${buildQueryString({
q: filters?.q,
status: filters?.status,
state: filters?.state,
})}`,
undefined,
token
);
},
getVendor(token: string, vendorId: string) {
return request<CrmRecordDetailDto>(`/api/v1/crm/vendors/${vendorId}`, undefined, token);
},
createVendor(token: string, payload: CrmRecordInput) {
return request<CrmRecordDetailDto>(
"/api/v1/crm/vendors",
{
method: "POST",
body: JSON.stringify(payload),
},
token
);
},
updateVendor(token: string, vendorId: string, payload: CrmRecordInput) {
return request<CrmRecordDetailDto>(
`/api/v1/crm/vendors/${vendorId}`,
{
method: "PUT",
body: JSON.stringify(payload),
},
token
);
},
getGanttDemo(token: string) {
return request<{ tasks: GanttTaskDto[]; links: GanttLinkDto[] }>("/api/v1/gantt/demo", undefined, token);

View File

@@ -10,6 +10,8 @@ import { AuthProvider } from "./auth/AuthProvider";
import { DashboardPage } from "./modules/dashboard/DashboardPage";
import { LoginPage } from "./modules/login/LoginPage";
import { CompanySettingsPage } from "./modules/settings/CompanySettingsPage";
import { CrmDetailPage } from "./modules/crm/CrmDetailPage";
import { CrmFormPage } from "./modules/crm/CrmFormPage";
import { CustomersPage } from "./modules/crm/CustomersPage";
import { VendorsPage } from "./modules/crm/VendorsPage";
import { GanttPage } from "./modules/gantt/GanttPage";
@@ -35,7 +37,18 @@ const router = createBrowserRouter([
element: <ProtectedRoute requiredPermissions={[permissions.crmRead]} />,
children: [
{ path: "/crm/customers", element: <CustomersPage /> },
{ path: "/crm/customers/:customerId", element: <CrmDetailPage entity="customer" /> },
{ path: "/crm/vendors", element: <VendorsPage /> },
{ path: "/crm/vendors/:vendorId", element: <CrmDetailPage entity="vendor" /> },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.crmWrite]} />,
children: [
{ path: "/crm/customers/new", element: <CrmFormPage entity="customer" mode="create" /> },
{ path: "/crm/customers/:customerId/edit", element: <CrmFormPage entity="customer" mode="edit" /> },
{ path: "/crm/vendors/new", element: <CrmFormPage entity="vendor" mode="create" /> },
{ path: "/crm/vendors/:vendorId/edit", element: <CrmFormPage entity="vendor" mode="edit" /> },
],
},
{
@@ -60,4 +73,3 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
</ThemeProvider>
</React.StrictMode>
);

View File

@@ -0,0 +1,113 @@
import { permissions } from "@mrp/shared";
import type { 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 { CrmStatusBadge } from "./CrmStatusBadge";
import { type CrmEntity, crmConfigs } from "./config";
interface CrmDetailPageProps {
entity: CrmEntity;
}
export function CrmDetailPage({ entity }: CrmDetailPageProps) {
const { token, user } = useAuth();
const { customerId, vendorId } = useParams();
const recordId = entity === "customer" ? customerId : vendorId;
const config = crmConfigs[entity];
const [record, setRecord] = useState<CrmRecordDetailDto | null>(null);
const [status, setStatus] = useState(`Loading ${config.singularLabel.toLowerCase()}...`);
const canManage = user?.permissions.includes(permissions.crmWrite) ?? false;
useEffect(() => {
if (!token || !recordId) {
return;
}
const loadRecord = entity === "customer" ? api.getCustomer(token, recordId) : api.getVendor(token, recordId);
loadRecord
.then((nextRecord) => {
setRecord(nextRecord);
setStatus(`${config.singularLabel} record loaded.`);
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : `Unable to load ${config.singularLabel.toLowerCase()}.`;
setStatus(message);
});
}, [config.singularLabel, entity, recordId, token]);
if (!record) {
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-8 text-sm text-muted shadow-panel">{status}</div>;
}
return (
<section className="space-y-6">
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM Detail</p>
<h3 className="mt-3 text-3xl font-bold text-text">{record.name}</h3>
<div className="mt-4">
<CrmStatusBadge status={record.status} />
</div>
<p className="mt-2 text-sm text-muted">
{config.singularLabel} record last updated {new Date(record.updatedAt).toLocaleString()}.
</p>
</div>
<div className="flex flex-wrap gap-3">
<Link
to={config.routeBase}
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-4 py-3 text-sm font-semibold text-text"
>
Back to {config.collectionLabel.toLowerCase()}
</Link>
{canManage ? (
<Link
to={`${config.routeBase}/${record.id}/edit`}
className="inline-flex items-center justify-center rounded-2xl bg-brand px-5 py-3 text-sm font-semibold text-white"
>
Edit {config.singularLabel.toLowerCase()}
</Link>
) : null}
</div>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<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">
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Email</dt>
<dd className="mt-2 text-base text-text">{record.email}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Phone</dt>
<dd className="mt-2 text-base text-text">{record.phone}</dd>
</div>
<div className="md:col-span-2">
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Address</dt>
<dd className="mt-2 whitespace-pre-line text-base text-text">
{[record.addressLine1, record.addressLine2, `${record.city}, ${record.state} ${record.postalCode}`, record.country]
.filter(Boolean)
.join("\n")}
</dd>
</div>
</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="mt-4 whitespace-pre-line text-sm leading-7 text-text">
{record.notes || "No internal notes recorded for this account yet."}
</p>
<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>
</article>
</div>
</section>
);
}

View File

@@ -0,0 +1,124 @@
import type { CrmRecordInput } from "@mrp/shared/dist/crm/types.js";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { CrmRecordForm } from "./CrmRecordForm";
import { type CrmEntity, crmConfigs, emptyCrmRecordInput } from "./config";
interface CrmFormPageProps {
entity: CrmEntity;
mode: "create" | "edit";
}
export function CrmFormPage({ entity, mode }: CrmFormPageProps) {
const navigate = useNavigate();
const { token } = useAuth();
const { customerId, vendorId } = useParams();
const recordId = entity === "customer" ? customerId : vendorId;
const config = crmConfigs[entity];
const [form, setForm] = useState<CrmRecordInput>(emptyCrmRecordInput);
const [status, setStatus] = useState(
mode === "create" ? `Create a new ${config.singularLabel.toLowerCase()} record.` : `Loading ${config.singularLabel.toLowerCase()}...`
);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (mode !== "edit" || !token || !recordId) {
return;
}
const loadRecord = entity === "customer" ? api.getCustomer(token, recordId) : api.getVendor(token, recordId);
loadRecord
.then((record) => {
setForm({
name: record.name,
email: record.email,
phone: record.phone,
addressLine1: record.addressLine1,
addressLine2: record.addressLine2,
city: record.city,
state: record.state,
postalCode: record.postalCode,
country: record.country,
status: record.status,
notes: record.notes,
});
setStatus(`${config.singularLabel} record loaded.`);
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : `Unable to load ${config.singularLabel.toLowerCase()}.`;
setStatus(message);
});
}, [config.singularLabel, entity, mode, recordId, token]);
function updateField<Key extends keyof CrmRecordInput>(key: Key, value: CrmRecordInput[Key]) {
setForm((current: CrmRecordInput) => ({ ...current, [key]: value }));
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token) {
return;
}
setIsSaving(true);
setStatus(`Saving ${config.singularLabel.toLowerCase()}...`);
try {
const savedRecord =
entity === "customer"
? mode === "create"
? await api.createCustomer(token, form)
: await api.updateCustomer(token, recordId ?? "", form)
: mode === "create"
? await api.createVendor(token, form)
: await api.updateVendor(token, recordId ?? "", form);
navigate(`${config.routeBase}/${savedRecord.id}`);
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : `Unable to save ${config.singularLabel.toLowerCase()}.`;
setStatus(message);
setIsSaving(false);
}
}
return (
<form className="space-y-6" onSubmit={handleSubmit}>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM Editor</p>
<h3 className="mt-3 text-2xl font-bold text-text">
{mode === "create" ? `New ${config.singularLabel}` : `Edit ${config.singularLabel}`}
</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">
Capture the operational contact and address details needed for quoting, purchasing, and shipping workflows.
</p>
</div>
<Link
to={mode === "create" ? config.routeBase : `${config.routeBase}/${recordId}`}
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-4 py-3 text-sm font-semibold text-text"
>
Cancel
</Link>
</div>
</section>
<section className="space-y-6 rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel">
<CrmRecordForm form={form} onChange={updateField} />
<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..." : mode === "create" ? `Create ${config.singularLabel}` : "Save changes"}
</button>
</div>
</section>
</form>
);
}

View File

@@ -0,0 +1,146 @@
import { permissions } from "@mrp/shared";
import type { CrmRecordStatus, CrmRecordSummaryDto } from "@mrp/shared/dist/crm/types.js";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { CrmStatusBadge } from "./CrmStatusBadge";
import { crmStatusFilters, type CrmEntity, crmConfigs } from "./config";
interface CrmListPageProps {
entity: CrmEntity;
}
export function CrmListPage({ entity }: CrmListPageProps) {
const { token, user } = useAuth();
const config = crmConfigs[entity];
const [records, setRecords] = useState<CrmRecordSummaryDto[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [stateFilter, setStateFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<"ALL" | CrmRecordStatus>("ALL");
const [status, setStatus] = useState(`Loading ${config.collectionLabel.toLowerCase()}...`);
const canManage = user?.permissions.includes(permissions.crmWrite) ?? false;
useEffect(() => {
if (!token) {
return;
}
const filters = {
q: searchTerm.trim() || undefined,
state: stateFilter.trim() || undefined,
status: statusFilter === "ALL" ? undefined : statusFilter,
};
const loadRecords = entity === "customer" ? api.getCustomers(token, filters) : api.getVendors(token, filters);
loadRecords
.then((nextRecords) => {
setRecords(nextRecords);
setStatus(`${nextRecords.length} ${config.collectionLabel.toLowerCase()} matched the current filters.`);
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : `Unable to load ${config.collectionLabel.toLowerCase()}.`;
setStatus(message);
});
}, [config.collectionLabel, entity, searchTerm, stateFilter, statusFilter, token]);
return (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM</p>
<h3 className="mt-3 text-2xl font-bold text-text">{config.collectionLabel}</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">
Operational contact records, shipping addresses, and account context for active {config.collectionLabel.toLowerCase()}.
</p>
</div>
{canManage ? (
<Link
to={`${config.routeBase}/new`}
className="inline-flex items-center justify-center rounded-2xl bg-brand px-5 py-3 text-sm font-semibold text-white"
>
New {config.singularLabel.toLowerCase()}
</Link>
) : null}
</div>
<div className="mt-6 grid gap-4 rounded-3xl border border-line/70 bg-page/60 p-4 lg:grid-cols-[1.4fr_0.8fr_0.8fr]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
<input
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder={`Search ${config.collectionLabel.toLowerCase()} by company, email, phone, or location`}
className="w-full rounded-2xl border border-line/70 bg-surface px-4 py-3 text-text outline-none transition focus:border-brand"
/>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
<select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as "ALL" | CrmRecordStatus)}
className="w-full rounded-2xl border border-line/70 bg-surface px-4 py-3 text-text outline-none transition focus:border-brand"
>
{crmStatusFilters.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">State / Province</span>
<input
value={stateFilter}
onChange={(event) => setStateFilter(event.target.value)}
placeholder="Filter by region"
className="w-full rounded-2xl border border-line/70 bg-surface px-4 py-3 text-text outline-none transition focus:border-brand"
/>
</label>
</div>
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-4 py-3 text-sm text-muted">{status}</div>
{records.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">
{config.emptyMessage}
</div>
) : (
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted">
<tr>
<th className="px-4 py-3">Name</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Email</th>
<th className="px-4 py-3">Phone</th>
<th className="px-4 py-3">Location</th>
<th className="px-4 py-3">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{records.map((record) => (
<tr key={record.id} className="transition hover:bg-page/70">
<td className="px-4 py-3 font-semibold text-text">
<Link to={`${config.routeBase}/${record.id}`} className="hover:text-brand">
{record.name}
</Link>
</td>
<td className="px-4 py-3">
<CrmStatusBadge status={record.status} />
</td>
<td className="px-4 py-3 text-muted">{record.email}</td>
<td className="px-4 py-3 text-muted">{record.phone}</td>
<td className="px-4 py-3 text-muted">
{record.city}, {record.state}, {record.country}
</td>
<td className="px-4 py-3 text-muted">{new Date(record.updatedAt).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,63 @@
import type { CrmRecordInput } from "@mrp/shared/dist/crm/types.js";
import { crmStatusOptions } from "./config";
const fields: Array<{ key: keyof CrmRecordInput; label: string; type?: string }> = [
{ key: "name", label: "Company name" },
{ key: "email", label: "Email", type: "email" },
{ key: "phone", label: "Phone" },
{ key: "addressLine1", label: "Address line 1" },
{ key: "addressLine2", label: "Address line 2" },
{ key: "city", label: "City" },
{ key: "state", label: "State / Province" },
{ key: "postalCode", label: "Postal code" },
{ key: "country", label: "Country" },
];
interface CrmRecordFormProps {
form: CrmRecordInput;
onChange: <Key extends keyof CrmRecordInput>(key: Key, value: CrmRecordInput[Key]) => void;
}
export function CrmRecordForm({ form, onChange }: CrmRecordFormProps) {
return (
<>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
<select
value={form.status}
onChange={(event) => onChange("status", event.target.value as CrmRecordInput["status"])}
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
>
{crmStatusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<div className="grid gap-5 md:grid-cols-2">
{fields.map((field) => (
<label key={String(field.key)} className="block">
<span className="mb-2 block text-sm font-semibold text-text">{field.label}</span>
<input
type={field.type ?? "text"}
value={form[field.key]}
onChange={(event) => onChange(field.key, 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="block">
<span className="mb-2 block text-sm font-semibold text-text">Internal notes</span>
<textarea
value={form.notes}
onChange={(event) => onChange("notes", 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"
/>
</label>
</>
);
}

View File

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

View File

@@ -1,46 +1,5 @@
import { useEffect, useState } from "react";
import { useAuth } from "../../auth/AuthProvider";
import { api } from "../../lib/api";
import { CrmListPage } from "./CrmListPage";
export function CustomersPage() {
const { token } = useAuth();
const [customers, setCustomers] = useState<Array<Record<string, string>>>([]);
useEffect(() => {
if (!token) {
return;
}
api.getCustomers(token).then(setCustomers);
}, [token]);
return (
<section 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">CRM</p>
<h3 className="mt-3 text-2xl font-bold text-text">Customers</h3>
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted">
<tr>
<th className="px-4 py-3">Name</th>
<th className="px-4 py-3">Email</th>
<th className="px-4 py-3">Phone</th>
<th className="px-4 py-3">Location</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{customers.map((customer) => (
<tr key={customer.id}>
<td className="px-4 py-3 font-semibold text-text">{customer.name}</td>
<td className="px-4 py-3 text-muted">{customer.email}</td>
<td className="px-4 py-3 text-muted">{customer.phone}</td>
<td className="px-4 py-3 text-muted">{customer.city}, {customer.state}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
return <CrmListPage entity="customer" />;
}

View File

@@ -1,34 +1,5 @@
import { useEffect, useState } from "react";
import { useAuth } from "../../auth/AuthProvider";
import { api } from "../../lib/api";
import { CrmListPage } from "./CrmListPage";
export function VendorsPage() {
const { token } = useAuth();
const [vendors, setVendors] = useState<Array<Record<string, string>>>([]);
useEffect(() => {
if (!token) {
return;
}
api.getVendors(token).then(setVendors);
}, [token]);
return (
<section 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">CRM</p>
<h3 className="mt-3 text-2xl font-bold text-text">Vendors</h3>
<div className="mt-6 grid gap-4 md:grid-cols-2">
{vendors.map((vendor) => (
<article key={vendor.id} className="rounded-2xl border border-line/70 bg-page/70 p-5">
<h4 className="text-lg font-bold text-text">{vendor.name}</h4>
<p className="mt-2 text-sm text-muted">{vendor.email}</p>
<p className="text-sm text-muted">{vendor.phone}</p>
<p className="mt-3 text-sm text-muted">{vendor.city}, {vendor.state}</p>
</article>
))}
</div>
</section>
);
return <CrmListPage entity="vendor" />;
}

View File

@@ -0,0 +1,63 @@
import { crmRecordStatuses, type CrmRecordInput, type CrmRecordStatus } from "@mrp/shared/dist/crm/types.js";
export type CrmEntity = "customer" | "vendor";
interface CrmModuleConfig {
entity: CrmEntity;
collectionLabel: string;
singularLabel: string;
routeBase: string;
emptyMessage: string;
}
export const crmConfigs: Record<CrmEntity, CrmModuleConfig> = {
customer: {
entity: "customer",
collectionLabel: "Customers",
singularLabel: "Customer",
routeBase: "/crm/customers",
emptyMessage: "No customer accounts have been added yet.",
},
vendor: {
entity: "vendor",
collectionLabel: "Vendors",
singularLabel: "Vendor",
routeBase: "/crm/vendors",
emptyMessage: "No vendor records have been added yet.",
},
};
export const emptyCrmRecordInput: CrmRecordInput = {
name: "",
email: "",
phone: "",
addressLine1: "",
addressLine2: "",
city: "",
state: "",
postalCode: "",
country: "USA",
status: "ACTIVE",
notes: "",
};
export const crmStatusOptions: Array<{ value: CrmRecordStatus; label: string }> = [
{ value: "LEAD", label: "Lead" },
{ value: "ACTIVE", label: "Active" },
{ value: "ON_HOLD", label: "On Hold" },
{ value: "INACTIVE", label: "Inactive" },
];
export const crmStatusFilters: Array<{ value: "ALL" | CrmRecordStatus; label: string }> = [
{ value: "ALL", label: "All statuses" },
...crmStatusOptions,
];
export const crmStatusPalette: Record<CrmRecordStatus, string> = {
LEAD: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
ACTIVE: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
ON_HOLD: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
INACTIVE: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
};
export { crmRecordStatuses };

View File

@@ -0,0 +1,7 @@
ALTER TABLE "Customer" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'ACTIVE';
ALTER TABLE "Vendor" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'ACTIVE';
CREATE INDEX "Customer_status_idx" ON "Customer"("status");
CREATE INDEX "Vendor_status_idx" ON "Vendor"("status");

View File

@@ -111,6 +111,7 @@ model Customer {
state String
postalCode String
country String
status String @default("ACTIVE")
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -127,6 +128,7 @@ model Vendor {
state String
postalCode String
country String
status String @default("ACTIVE")
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -1,17 +1,158 @@
import { permissions } from "@mrp/shared";
import { crmRecordStatuses, permissions } from "@mrp/shared";
import { Router } from "express";
import { z } from "zod";
import { ok } from "../../lib/http.js";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import { listCustomers, listVendors } from "./service.js";
import {
createCustomer,
createVendor,
getCustomerById,
getVendorById,
listCustomers,
listVendors,
updateCustomer,
updateVendor,
} from "./service.js";
const crmRecordSchema = z.object({
name: z.string().trim().min(1),
email: z.string().trim().email(),
phone: z.string().trim().min(1),
addressLine1: z.string().trim().min(1),
addressLine2: z.string(),
city: z.string().trim().min(1),
state: z.string().trim().min(1),
postalCode: z.string().trim().min(1),
country: z.string().trim().min(1),
status: z.enum(crmRecordStatuses),
notes: z.string(),
});
const crmListQuerySchema = z.object({
q: z.string().optional(),
state: z.string().optional(),
status: z.enum(crmRecordStatuses).optional(),
});
function getRouteParam(value: string | string[] | undefined) {
return typeof value === "string" ? value : null;
}
export const crmRouter = Router();
crmRouter.get("/customers", requirePermissions([permissions.crmRead]), async (_request, response) => {
return ok(response, await listCustomers());
const parsed = crmListQuerySchema.safeParse(_request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "CRM filters are invalid.");
}
return ok(
response,
await listCustomers({
query: parsed.data.q,
status: parsed.data.status,
state: parsed.data.state,
})
);
});
crmRouter.get("/customers/:customerId", requirePermissions([permissions.crmRead]), async (request, response) => {
const customerId = getRouteParam(request.params.customerId);
if (!customerId) {
return fail(response, 400, "INVALID_INPUT", "Customer id is invalid.");
}
const customer = await getCustomerById(customerId);
if (!customer) {
return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found.");
}
return ok(response, customer);
});
crmRouter.post("/customers", requirePermissions([permissions.crmWrite]), async (request, response) => {
const parsed = crmRecordSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Customer payload is invalid.");
}
return ok(response, await createCustomer(parsed.data), 201);
});
crmRouter.put("/customers/:customerId", 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 = crmRecordSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Customer payload is invalid.");
}
const customer = await updateCustomer(customerId, parsed.data);
if (!customer) {
return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found.");
}
return ok(response, customer);
});
crmRouter.get("/vendors", requirePermissions([permissions.crmRead]), async (_request, response) => {
return ok(response, await listVendors());
const parsed = crmListQuerySchema.safeParse(_request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "CRM filters are invalid.");
}
return ok(
response,
await listVendors({
query: parsed.data.q,
status: parsed.data.status,
state: parsed.data.state,
})
);
});
crmRouter.get("/vendors/:vendorId", requirePermissions([permissions.crmRead]), async (request, response) => {
const vendorId = getRouteParam(request.params.vendorId);
if (!vendorId) {
return fail(response, 400, "INVALID_INPUT", "Vendor id is invalid.");
}
const vendor = await getVendorById(vendorId);
if (!vendor) {
return fail(response, 404, "CRM_VENDOR_NOT_FOUND", "Vendor record was not found.");
}
return ok(response, vendor);
});
crmRouter.post("/vendors", requirePermissions([permissions.crmWrite]), async (request, response) => {
const parsed = crmRecordSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Vendor payload is invalid.");
}
return ok(response, await createVendor(parsed.data), 201);
});
crmRouter.put("/vendors/:vendorId", 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 = crmRecordSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Vendor payload is invalid.");
}
const vendor = await updateVendor(vendorId, parsed.data);
if (!vendor) {
return fail(response, 404, "CRM_VENDOR_NOT_FOUND", "Vendor record was not found.");
}
return ok(response, vendor);
});

View File

@@ -1,14 +1,147 @@
import type {
CrmRecordDetailDto,
CrmRecordInput,
CrmRecordStatus,
CrmRecordSummaryDto,
} from "@mrp/shared/dist/crm/types.js";
import type { Customer, Vendor } from "@prisma/client";
import { prisma } from "../../lib/prisma.js";
export async function listCustomers() {
return prisma.customer.findMany({
orderBy: { name: "asc" },
});
function mapSummary(record: Customer | Vendor): CrmRecordSummaryDto {
return {
id: record.id,
name: record.name,
email: record.email,
phone: record.phone,
city: record.city,
state: record.state,
country: record.country,
status: record.status as CrmRecordStatus,
updatedAt: record.updatedAt.toISOString(),
};
}
export async function listVendors() {
return prisma.vendor.findMany({
orderBy: { name: "asc" },
});
function mapDetail(record: Customer | Vendor): CrmRecordDetailDto {
return {
...mapSummary(record),
addressLine1: record.addressLine1,
addressLine2: record.addressLine2,
postalCode: record.postalCode,
notes: record.notes,
createdAt: record.createdAt.toISOString(),
};
}
interface CrmListFilters {
query?: string;
status?: CrmRecordStatus;
state?: string;
}
function buildWhereClause(filters: CrmListFilters) {
const trimmedQuery = filters.query?.trim();
const trimmedState = filters.state?.trim();
return {
...(filters.status ? { status: filters.status } : {}),
...(trimmedState ? { state: { contains: trimmedState } } : {}),
...(trimmedQuery
? {
OR: [
{ name: { contains: trimmedQuery } },
{ email: { contains: trimmedQuery } },
{ phone: { contains: trimmedQuery } },
{ city: { contains: trimmedQuery } },
{ state: { contains: trimmedQuery } },
{ postalCode: { contains: trimmedQuery } },
{ country: { contains: trimmedQuery } },
],
}
: {}),
};
}
export async function listCustomers(filters: CrmListFilters = {}) {
const customers = await prisma.customer.findMany({
where: buildWhereClause(filters),
orderBy: { name: "asc" },
});
return customers.map(mapSummary);
}
export async function getCustomerById(customerId: string) {
const customer = await prisma.customer.findUnique({
where: { id: customerId },
});
return customer ? mapDetail(customer) : null;
}
export async function createCustomer(payload: CrmRecordInput) {
const customer = await prisma.customer.create({
data: payload,
});
return mapDetail(customer);
}
export async function updateCustomer(customerId: string, payload: CrmRecordInput) {
const existingCustomer = await prisma.customer.findUnique({
where: { id: customerId },
});
if (!existingCustomer) {
return null;
}
const customer = await prisma.customer.update({
where: { id: customerId },
data: payload,
});
return mapDetail(customer);
}
export async function listVendors(filters: CrmListFilters = {}) {
const vendors = await prisma.vendor.findMany({
where: buildWhereClause(filters),
orderBy: { name: "asc" },
});
return vendors.map(mapSummary);
}
export async function getVendorById(vendorId: string) {
const vendor = await prisma.vendor.findUnique({
where: { id: vendorId },
});
return vendor ? mapDetail(vendor) : null;
}
export async function createVendor(payload: CrmRecordInput) {
const vendor = await prisma.vendor.create({
data: payload,
});
return mapDetail(vendor);
}
export async function updateVendor(vendorId: string, payload: CrmRecordInput) {
const existingVendor = await prisma.vendor.findUnique({
where: { id: vendorId },
});
if (!existingVendor) {
return null;
}
const vendor = await prisma.vendor.update({
where: { id: vendorId },
data: payload,
});
return mapDetail(vendor);
}

45
shared/src/crm/types.ts Normal file
View File

@@ -0,0 +1,45 @@
export const crmRecordStatuses = ["LEAD", "ACTIVE", "ON_HOLD", "INACTIVE"] as const;
export type CrmRecordStatus = (typeof crmRecordStatuses)[number];
export interface CrmRecordSummaryDto {
id: string;
name: string;
email: string;
phone: string;
city: string;
state: string;
country: string;
status: CrmRecordStatus;
updatedAt: string;
}
export interface CrmRecordDetailDto extends CrmRecordSummaryDto {
addressLine1: string;
addressLine2: string;
postalCode: string;
notes: string;
createdAt: string;
}
export interface CrmRecordInput {
name: string;
email: string;
phone: string;
addressLine1: string;
addressLine2: string;
city: string;
state: string;
postalCode: string;
country: string;
status: CrmRecordStatus;
notes: string;
}
export type CustomerSummaryDto = CrmRecordSummaryDto;
export type CustomerDetailDto = CrmRecordDetailDto;
export type CustomerInput = CrmRecordInput;
export type VendorSummaryDto = CrmRecordSummaryDto;
export type VendorDetailDto = CrmRecordDetailDto;
export type VendorInput = CrmRecordInput;

View File

@@ -2,5 +2,6 @@ export * from "./auth/permissions.js";
export * from "./auth/types.js";
export * from "./common/api.js";
export * from "./company/types.js";
export * from "./crm/types.js";
export * from "./files/types.js";
export * from "./gantt/types.js";