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` - Local file attachment storage under `/app/data/uploads`
- Puppeteer PDF service foundation with branded company-profile preview - Puppeteer PDF service foundation with branded company-profile preview
- CRM reference entities for customers and vendors - 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 - SVAR Gantt integration wrapper with demo planning data
- Multi-stage Docker packaging and migration-aware entrypoint - 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` - Core project documentation in `README.md`, `INSTRUCTIONS.md`, and `STRUCTURE.md`
### Current known gaps in the foundation ### 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 - 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 - 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 ## Planned feature phases
### Phase 1: CRM and master data hardening ### Phase 1: CRM and master data hardening
- Customer and vendor create/edit/detail pages
- Search, filters, and status tagging
- Contact history and internal notes - Contact history and internal notes
- Shared attachment support on CRM entities - Shared attachment support on CRM entities
- Better seed/bootstrap strategy for non-development environments - Better seed/bootstrap strategy for non-development environments
- Deeper CRM operational fields and lifecycle reporting
### Phase 2: Inventory and manufacturing core ### 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 ## Near-term priority order
1. CRM detail and edit workflows 1. CRM contact history and internal notes
2. Inventory item and BOM data model 2. CRM shared attachments and operational metadata
3. Sales order and quote foundation 3. Inventory item and BOM data model
4. Shipping module tied to sales orders 4. Sales order and quote foundation
5. Live manufacturing gantt scheduling 5. Shipping module tied to sales orders

View File

@@ -8,6 +8,12 @@ import type {
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
} from "@mrp/shared"; } from "@mrp/shared";
import type {
CrmRecordDetailDto,
CrmRecordInput,
CrmRecordStatus,
CrmRecordSummaryDto,
} from "@mrp/shared/dist/crm/types.js";
export class ApiError extends Error { export class ApiError extends Error {
constructor(message: string, public readonly code: string) { 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; 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 = { export const api = {
login(payload: LoginRequest) { login(payload: LoginRequest) {
return request<LoginResponse>("/api/v1/auth/login", { return request<LoginResponse>("/api/v1/auth/login", {
@@ -75,11 +93,73 @@ export const api = {
} }
return json.data; return json.data;
}, },
getCustomers(token: string) { getCustomers(token: string, filters?: { q?: string; status?: CrmRecordStatus; state?: string }) {
return request<Array<Record<string, string>>>("/api/v1/crm/customers", undefined, token); return request<CrmRecordSummaryDto[]>(
`/api/v1/crm/customers${buildQueryString({
q: filters?.q,
status: filters?.status,
state: filters?.state,
})}`,
undefined,
token
);
}, },
getVendors(token: string) { getCustomer(token: string, customerId: string) {
return request<Array<Record<string, string>>>("/api/v1/crm/vendors", undefined, token); 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) { getGanttDemo(token: string) {
return request<{ tasks: GanttTaskDto[]; links: GanttLinkDto[] }>("/api/v1/gantt/demo", undefined, token); 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 { DashboardPage } from "./modules/dashboard/DashboardPage";
import { LoginPage } from "./modules/login/LoginPage"; import { LoginPage } from "./modules/login/LoginPage";
import { CompanySettingsPage } from "./modules/settings/CompanySettingsPage"; 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 { CustomersPage } from "./modules/crm/CustomersPage";
import { VendorsPage } from "./modules/crm/VendorsPage"; import { VendorsPage } from "./modules/crm/VendorsPage";
import { GanttPage } from "./modules/gantt/GanttPage"; import { GanttPage } from "./modules/gantt/GanttPage";
@@ -35,7 +37,18 @@ const router = createBrowserRouter([
element: <ProtectedRoute requiredPermissions={[permissions.crmRead]} />, element: <ProtectedRoute requiredPermissions={[permissions.crmRead]} />,
children: [ children: [
{ path: "/crm/customers", element: <CustomersPage /> }, { path: "/crm/customers", element: <CustomersPage /> },
{ path: "/crm/customers/:customerId", element: <CrmDetailPage entity="customer" /> },
{ path: "/crm/vendors", element: <VendorsPage /> }, { 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> </ThemeProvider>
</React.StrictMode> </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 { CrmListPage } from "./CrmListPage";
import { useAuth } from "../../auth/AuthProvider";
import { api } from "../../lib/api";
export function CustomersPage() { export function CustomersPage() {
const { token } = useAuth(); return <CrmListPage entity="customer" />;
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>
);
} }

View File

@@ -1,34 +1,5 @@
import { useEffect, useState } from "react"; import { CrmListPage } from "./CrmListPage";
import { useAuth } from "../../auth/AuthProvider";
import { api } from "../../lib/api";
export function VendorsPage() { export function VendorsPage() {
const { token } = useAuth(); return <CrmListPage entity="vendor" />;
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>
);
} }

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 state String
postalCode String postalCode String
country String country String
status String @default("ACTIVE")
notes String notes String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -127,6 +128,7 @@ model Vendor {
state String state String
postalCode String postalCode String
country String country String
status String @default("ACTIVE")
notes String notes String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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 { 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 { 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(); export const crmRouter = Router();
crmRouter.get("/customers", requirePermissions([permissions.crmRead]), async (_request, response) => { 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) => { 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"; import { prisma } from "../../lib/prisma.js";
export async function listCustomers() { function mapSummary(record: Customer | Vendor): CrmRecordSummaryDto {
return prisma.customer.findMany({ return {
orderBy: { name: "asc" }, 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() { function mapDetail(record: Customer | Vendor): CrmRecordDetailDto {
return prisma.vendor.findMany({ return {
orderBy: { name: "asc" }, ...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 "./auth/types.js";
export * from "./common/api.js"; export * from "./common/api.js";
export * from "./company/types.js"; export * from "./company/types.js";
export * from "./crm/types.js";
export * from "./files/types.js"; export * from "./files/types.js";
export * from "./gantt/types.js"; export * from "./gantt/types.js";