CRM
This commit is contained in:
20
ROADMAP.md
20
ROADMAP.md
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
113
client/src/modules/crm/CrmDetailPage.tsx
Normal file
113
client/src/modules/crm/CrmDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
client/src/modules/crm/CrmFormPage.tsx
Normal file
124
client/src/modules/crm/CrmFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
client/src/modules/crm/CrmListPage.tsx
Normal file
146
client/src/modules/crm/CrmListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
client/src/modules/crm/CrmRecordForm.tsx
Normal file
63
client/src/modules/crm/CrmRecordForm.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
client/src/modules/crm/CrmStatusBadge.tsx
Normal file
13
client/src/modules/crm/CrmStatusBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
|
||||
63
client/src/modules/crm/config.ts
Normal file
63
client/src/modules/crm/config.ts
Normal 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 };
|
||||
@@ -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");
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
45
shared/src/crm/types.ts
Normal 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;
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user