crm4
This commit is contained in:
@@ -9,8 +9,11 @@ import type {
|
||||
LoginResponse,
|
||||
} from "@mrp/shared";
|
||||
import type {
|
||||
CrmContactDto,
|
||||
CrmContactInput,
|
||||
CrmContactEntryDto,
|
||||
CrmContactEntryInput,
|
||||
CrmCustomerHierarchyOptionDto,
|
||||
CrmRecordDetailDto,
|
||||
CrmRecordInput,
|
||||
CrmRecordStatus,
|
||||
@@ -132,6 +135,15 @@ export const api = {
|
||||
getCustomer(token: string, customerId: string) {
|
||||
return request<CrmRecordDetailDto>(`/api/v1/crm/customers/${customerId}`, undefined, token);
|
||||
},
|
||||
getCustomerHierarchyOptions(token: string, excludeCustomerId?: string) {
|
||||
return request<CrmCustomerHierarchyOptionDto[]>(
|
||||
`/api/v1/crm/customers/hierarchy-options${buildQueryString({
|
||||
excludeCustomerId,
|
||||
})}`,
|
||||
undefined,
|
||||
token
|
||||
);
|
||||
},
|
||||
createCustomer(token: string, payload: CrmRecordInput) {
|
||||
return request<CrmRecordDetailDto>(
|
||||
"/api/v1/crm/customers",
|
||||
@@ -162,6 +174,16 @@ export const api = {
|
||||
token
|
||||
);
|
||||
},
|
||||
createCustomerContact(token: string, customerId: string, payload: CrmContactInput) {
|
||||
return request<CrmContactDto>(
|
||||
`/api/v1/crm/customers/${customerId}/contacts`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
token
|
||||
);
|
||||
},
|
||||
getVendors(token: string, filters?: { q?: string; status?: CrmRecordStatus; state?: string }) {
|
||||
return request<CrmRecordSummaryDto[]>(
|
||||
`/api/v1/crm/vendors${buildQueryString({
|
||||
@@ -206,6 +228,16 @@ export const api = {
|
||||
token
|
||||
);
|
||||
},
|
||||
createVendorContact(token: string, vendorId: string, payload: CrmContactInput) {
|
||||
return request<CrmContactDto>(
|
||||
`/api/v1/crm/vendors/${vendorId}/contacts`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
token
|
||||
);
|
||||
},
|
||||
getGanttDemo(token: string) {
|
||||
return request<{ tasks: GanttTaskDto[]; links: GanttLinkDto[] }>("/api/v1/gantt/demo", undefined, token);
|
||||
},
|
||||
|
||||
153
client/src/modules/crm/CrmContactsPanel.tsx
Normal file
153
client/src/modules/crm/CrmContactsPanel.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { CrmContactDto, CrmContactInput } from "@mrp/shared/dist/crm/types.js";
|
||||
import { permissions } from "@mrp/shared";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { crmContactRoleOptions, emptyCrmContactInput, type CrmEntity } from "./config";
|
||||
|
||||
interface CrmContactsPanelProps {
|
||||
entity: CrmEntity;
|
||||
ownerId: string;
|
||||
contacts: CrmContactDto[];
|
||||
onContactsChange: (contacts: CrmContactDto[]) => void;
|
||||
}
|
||||
|
||||
export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }: CrmContactsPanelProps) {
|
||||
const { token, user } = useAuth();
|
||||
const [form, setForm] = useState<CrmContactInput>(emptyCrmContactInput);
|
||||
const [status, setStatus] = useState("Add account contacts for purchasing, AP, shipping, and engineering.");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const canManage = user?.permissions.includes(permissions.crmWrite) ?? false;
|
||||
|
||||
function updateField<Key extends keyof CrmContactInput>(key: Key, value: CrmContactInput[Key]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setStatus("Saving contact...");
|
||||
|
||||
try {
|
||||
const nextContact =
|
||||
entity === "customer"
|
||||
? await api.createCustomerContact(token, ownerId, form)
|
||||
: await api.createVendorContact(token, ownerId, form);
|
||||
|
||||
onContactsChange(
|
||||
[nextContact, ...contacts]
|
||||
.sort((left, right) => Number(right.isPrimary) - Number(left.isPrimary) || left.fullName.localeCompare(right.fullName))
|
||||
);
|
||||
setForm({
|
||||
...emptyCrmContactInput,
|
||||
isPrimary: false,
|
||||
});
|
||||
setStatus("Contact added.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to save CRM contact.";
|
||||
setStatus(message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="min-w-0 rounded-[28px] border border-line/70 bg-surface/90 p-6 shadow-panel 2xl:p-7">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Contacts</p>
|
||||
<h4 className="mt-3 text-xl font-bold text-text">People on this account</h4>
|
||||
<div className="mt-5 space-y-3">
|
||||
{contacts.length === 0 ? (
|
||||
<div className="rounded-3xl border border-dashed border-line/70 bg-page/60 px-6 py-10 text-center text-sm text-muted">
|
||||
No contacts have been added yet.
|
||||
</div>
|
||||
) : (
|
||||
contacts.map((contact) => (
|
||||
<div key={contact.id} className="rounded-3xl border border-line/70 bg-page/60 px-4 py-4">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text">
|
||||
{contact.fullName} {contact.isPrimary ? <span className="text-brand">• Primary</span> : null}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted">{crmContactRoleOptions.find((option) => option.value === contact.role)?.label ?? contact.role}</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted lg:text-right">
|
||||
<div>{contact.email}</div>
|
||||
<div>{contact.phone}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{canManage ? (
|
||||
<form className="mt-5 space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Full name</span>
|
||||
<input
|
||||
value={form.fullName}
|
||||
onChange={(event) => updateField("fullName", event.target.value)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Role</span>
|
||||
<select
|
||||
value={form.role}
|
||||
onChange={(event) => updateField("role", event.target.value as CrmContactInput["role"])}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
|
||||
>
|
||||
{crmContactRoleOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Email</span>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(event) => updateField("email", event.target.value)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Phone</span>
|
||||
<input
|
||||
value={form.phone}
|
||||
onChange={(event) => updateField("phone", event.target.value)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isPrimary}
|
||||
onChange={(event) => updateField("isPrimary", event.target.checked)}
|
||||
/>
|
||||
<span className="text-sm font-semibold text-text">Primary contact</span>
|
||||
</label>
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="text-sm text-muted">{status}</span>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="rounded-2xl bg-brand px-5 py-3 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isSaving ? "Saving..." : "Add contact"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import type { CrmContactEntryInput, CrmRecordDetailDto } from "@mrp/shared/dist/crm/types.js";
|
||||
import type { CrmContactDto, CrmContactEntryInput, CrmRecordDetailDto } from "@mrp/shared/dist/crm/types.js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { CrmAttachmentsPanel } from "./CrmAttachmentsPanel";
|
||||
import { CrmContactsPanel } from "./CrmContactsPanel";
|
||||
import { CrmContactEntryForm } from "./CrmContactEntryForm";
|
||||
import { CrmContactTypeBadge } from "./CrmContactTypeBadge";
|
||||
import { CrmStatusBadge } from "./CrmStatusBadge";
|
||||
@@ -145,6 +146,15 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
|
||||
.join("\n")}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Commercial terms</dt>
|
||||
<dd className="mt-2 grid gap-3 text-sm text-text md:grid-cols-2">
|
||||
<div>Payment terms: {record.paymentTerms ?? "Not set"}</div>
|
||||
<div>Currency: {record.currencyCode ?? "USD"}</div>
|
||||
<div>Tax exempt: {record.taxExempt ? "Yes" : "No"}</div>
|
||||
<div>Credit hold: {record.creditHold ? "Yes" : "No"}</div>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
<article className="min-w-0 rounded-[28px] border border-line/70 bg-surface/90 p-6 shadow-panel 2xl:p-7">
|
||||
@@ -155,8 +165,56 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
|
||||
<div className="mt-8 rounded-2xl border border-line/70 bg-page/70 px-4 py-4 text-sm text-muted">
|
||||
Created {new Date(record.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
{entity === "customer" ? (
|
||||
<div className="mt-4 rounded-2xl border border-line/70 bg-page/70 px-4 py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Reseller Profile</p>
|
||||
<div className="mt-3 grid gap-3 text-sm text-text">
|
||||
<div>
|
||||
<span className="font-semibold">Account type:</span>{" "}
|
||||
{record.isReseller ? "Reseller" : record.parentCustomerName ? "End customer" : "Direct customer"}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Discount:</span> {(record.resellerDiscountPercent ?? 0).toFixed(2)}%
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Parent reseller:</span> {record.parentCustomerName ?? "None"}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Child accounts:</span> {record.childCustomers?.length ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
</div>
|
||||
{entity === "customer" && (record.childCustomers?.length ?? 0) > 0 ? (
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-6 shadow-panel 2xl:p-7">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Hierarchy</p>
|
||||
<h4 className="mt-3 text-xl font-bold text-text">End customers under this reseller</h4>
|
||||
<div className="mt-5 grid gap-3 xl:grid-cols-2 2xl:grid-cols-3">
|
||||
{record.childCustomers?.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
to={`/crm/customers/${child.id}`}
|
||||
className="rounded-3xl border border-line/70 bg-page/60 px-4 py-4 transition hover:border-brand/50 hover:bg-page/80"
|
||||
>
|
||||
<div className="text-sm font-semibold text-text">{child.name}</div>
|
||||
<div className="mt-2">
|
||||
<CrmStatusBadge status={child.status} />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<CrmContactsPanel
|
||||
entity={entity}
|
||||
ownerId={record.id}
|
||||
contacts={record.contacts ?? []}
|
||||
onContactsChange={(contacts: CrmContactDto[]) =>
|
||||
setRecord((current) => (current ? { ...current, contacts } : current))
|
||||
}
|
||||
/>
|
||||
<section className="grid gap-4 2xl:grid-cols-[minmax(360px,0.88fr)_minmax(0,1.12fr)]">
|
||||
{canManage ? (
|
||||
<article className="min-w-0 rounded-[28px] border border-line/70 bg-surface/90 p-6 shadow-panel 2xl:p-7">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CrmRecordInput } from "@mrp/shared/dist/crm/types.js";
|
||||
import type { CrmCustomerHierarchyOptionDto, CrmRecordInput } from "@mrp/shared/dist/crm/types.js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
@@ -19,11 +19,23 @@ export function CrmFormPage({ entity, mode }: CrmFormPageProps) {
|
||||
const recordId = entity === "customer" ? customerId : vendorId;
|
||||
const config = crmConfigs[entity];
|
||||
const [form, setForm] = useState<CrmRecordInput>(emptyCrmRecordInput);
|
||||
const [hierarchyOptions, setHierarchyOptions] = useState<CrmCustomerHierarchyOptionDto[]>([]);
|
||||
const [status, setStatus] = useState(
|
||||
mode === "create" ? `Create a new ${config.singularLabel.toLowerCase()} record.` : `Loading ${config.singularLabel.toLowerCase()}...`
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (entity !== "customer" || !token) {
|
||||
return;
|
||||
}
|
||||
|
||||
api
|
||||
.getCustomerHierarchyOptions(token, mode === "edit" ? recordId : undefined)
|
||||
.then(setHierarchyOptions)
|
||||
.catch(() => setHierarchyOptions([]));
|
||||
}, [entity, mode, recordId, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "edit" || !token || !recordId) {
|
||||
return;
|
||||
@@ -44,6 +56,13 @@ export function CrmFormPage({ entity, mode }: CrmFormPageProps) {
|
||||
postalCode: record.postalCode,
|
||||
country: record.country,
|
||||
status: record.status,
|
||||
isReseller: record.isReseller ?? false,
|
||||
resellerDiscountPercent: record.resellerDiscountPercent ?? 0,
|
||||
parentCustomerId: record.parentCustomerId ?? null,
|
||||
paymentTerms: record.paymentTerms ?? "Net 30",
|
||||
currencyCode: record.currencyCode ?? "USD",
|
||||
taxExempt: record.taxExempt ?? false,
|
||||
creditHold: record.creditHold ?? false,
|
||||
notes: record.notes,
|
||||
});
|
||||
setStatus(`${config.singularLabel} record loaded.`);
|
||||
@@ -107,7 +126,7 @@ export function CrmFormPage({ entity, mode }: CrmFormPageProps) {
|
||||
</div>
|
||||
</section>
|
||||
<section className="space-y-5 rounded-[28px] border border-line/70 bg-surface/90 p-6 shadow-panel 2xl:p-7">
|
||||
<CrmRecordForm form={form} onChange={updateField} />
|
||||
<CrmRecordForm entity={entity} form={form} hierarchyOptions={hierarchyOptions} onChange={updateField} />
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="min-w-0 text-sm text-muted">{status}</span>
|
||||
<button
|
||||
|
||||
@@ -125,6 +125,12 @@ export function CrmListPage({ entity }: CrmListPageProps) {
|
||||
<Link to={`${config.routeBase}/${record.id}`} className="hover:text-brand">
|
||||
{record.name}
|
||||
</Link>
|
||||
{entity === "customer" && (record.isReseller || record.parentCustomerName) ? (
|
||||
<div className="mt-1 flex flex-wrap gap-2 text-xs font-medium text-muted">
|
||||
{record.isReseller ? <span>Reseller</span> : null}
|
||||
{record.parentCustomerName ? <span>Child of {record.parentCustomerName}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<CrmStatusBadge status={record.status} />
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { CrmRecordInput } from "@mrp/shared/dist/crm/types.js";
|
||||
import type { CrmCustomerHierarchyOptionDto, CrmRecordInput } from "@mrp/shared/dist/crm/types.js";
|
||||
|
||||
import { crmStatusOptions } from "./config";
|
||||
import type { CrmEntity } from "./config";
|
||||
|
||||
const fields: Array<{ key: keyof CrmRecordInput; label: string; type?: string }> = [
|
||||
const fields: Array<{
|
||||
key: "name" | "email" | "phone" | "addressLine1" | "addressLine2" | "city" | "state" | "postalCode" | "country";
|
||||
label: string;
|
||||
type?: string;
|
||||
}> = [
|
||||
{ key: "name", label: "Company name" },
|
||||
{ key: "email", label: "Email", type: "email" },
|
||||
{ key: "phone", label: "Phone" },
|
||||
@@ -15,11 +20,13 @@ const fields: Array<{ key: keyof CrmRecordInput; label: string; type?: string }>
|
||||
];
|
||||
|
||||
interface CrmRecordFormProps {
|
||||
entity: CrmEntity;
|
||||
form: CrmRecordInput;
|
||||
hierarchyOptions?: CrmCustomerHierarchyOptionDto[];
|
||||
onChange: <Key extends keyof CrmRecordInput>(key: Key, value: CrmRecordInput[Key]) => void;
|
||||
}
|
||||
|
||||
export function CrmRecordForm({ form, onChange }: CrmRecordFormProps) {
|
||||
export function CrmRecordForm({ entity, form, hierarchyOptions = [], onChange }: CrmRecordFormProps) {
|
||||
return (
|
||||
<>
|
||||
<label className="block">
|
||||
@@ -36,6 +43,87 @@ export function CrmRecordForm({ form, onChange }: CrmRecordFormProps) {
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{entity === "customer" ? (
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)_minmax(0,1fr)]">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Reseller account</span>
|
||||
<select
|
||||
value={form.isReseller ? "yes" : "no"}
|
||||
onChange={(event) => onChange("isReseller", event.target.value === "yes")}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
|
||||
>
|
||||
<option value="no">Standard customer</option>
|
||||
<option value="yes">Reseller</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Reseller discount %</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.01}
|
||||
value={form.resellerDiscountPercent ?? 0}
|
||||
disabled={!form.isReseller}
|
||||
onChange={(event) =>
|
||||
onChange("resellerDiscountPercent", event.target.value === "" ? 0 : Number(event.target.value))
|
||||
}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Parent reseller</span>
|
||||
<select
|
||||
value={form.parentCustomerId ?? ""}
|
||||
onChange={(event) => onChange("parentCustomerId", event.target.value || null)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
|
||||
>
|
||||
<option value="">No parent reseller</option>
|
||||
{hierarchyOptions.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.name} {option.isReseller ? "(Reseller)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid gap-4 xl:grid-cols-4">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Payment terms</span>
|
||||
<input
|
||||
value={form.paymentTerms ?? ""}
|
||||
onChange={(event) => onChange("paymentTerms", event.target.value || null)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
|
||||
placeholder="Net 30"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">Currency</span>
|
||||
<input
|
||||
value={form.currencyCode ?? "USD"}
|
||||
onChange={(event) => onChange("currencyCode", event.target.value.toUpperCase() || null)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
|
||||
maxLength={8}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.taxExempt ?? false}
|
||||
onChange={(event) => onChange("taxExempt", event.target.checked)}
|
||||
/>
|
||||
<span className="text-sm font-semibold text-text">Tax exempt</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.creditHold ?? false}
|
||||
onChange={(event) => onChange("creditHold", event.target.checked)}
|
||||
/>
|
||||
<span className="text-sm font-semibold text-text">Credit hold</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-4 xl:grid-cols-2 2xl:grid-cols-3">
|
||||
{fields.map((field) => (
|
||||
<label key={String(field.key)} className="block">
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import {
|
||||
crmContactRoles,
|
||||
crmContactEntryTypes,
|
||||
crmRecordStatuses,
|
||||
type CrmContactInput,
|
||||
type CrmContactRole,
|
||||
type CrmContactEntryInput,
|
||||
type CrmContactEntryType,
|
||||
type CrmRecordInput,
|
||||
@@ -49,6 +52,13 @@ export const emptyCrmRecordInput: CrmRecordInput = {
|
||||
country: "USA",
|
||||
status: "ACTIVE",
|
||||
notes: "",
|
||||
isReseller: false,
|
||||
resellerDiscountPercent: 0,
|
||||
parentCustomerId: null,
|
||||
paymentTerms: "Net 30",
|
||||
currencyCode: "USD",
|
||||
taxExempt: false,
|
||||
creditHold: false,
|
||||
};
|
||||
|
||||
export const crmStatusOptions: Array<{ value: CrmRecordStatus; label: string }> = [
|
||||
@@ -77,6 +87,14 @@ export const emptyCrmContactEntryInput: CrmContactEntryInput = {
|
||||
contactAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
export const emptyCrmContactInput: CrmContactInput = {
|
||||
fullName: "",
|
||||
role: "PRIMARY",
|
||||
email: "",
|
||||
phone: "",
|
||||
isPrimary: true,
|
||||
};
|
||||
|
||||
export const crmContactTypeOptions: Array<{ value: CrmContactEntryType; label: string }> = [
|
||||
{ value: "NOTE", label: "Note" },
|
||||
{ value: "CALL", label: "Call" },
|
||||
@@ -91,4 +109,14 @@ export const crmContactTypePalette: Record<CrmContactEntryType, string> = {
|
||||
MEETING: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
|
||||
};
|
||||
|
||||
export { crmContactEntryTypes, crmRecordStatuses };
|
||||
export const crmContactRoleOptions: Array<{ value: CrmContactRole; label: string }> = [
|
||||
{ value: "PRIMARY", label: "Primary" },
|
||||
{ value: "PURCHASING", label: "Purchasing" },
|
||||
{ value: "AP", label: "Accounts Payable" },
|
||||
{ value: "SHIPPING", label: "Shipping" },
|
||||
{ value: "ENGINEERING", label: "Engineering" },
|
||||
{ value: "SALES", label: "Sales" },
|
||||
{ value: "OTHER", label: "Other" },
|
||||
];
|
||||
|
||||
export { crmContactEntryTypes, crmContactRoles, crmRecordStatuses };
|
||||
|
||||
Reference in New Issue
Block a user