shoc-frontend-new/src/domain/vendors/mappers/vendor-roster-mapper.ts
Arthur Bassi 3c5941b895 chore(work-orders): resolve SH-388 uplift conflicts onto current main
Keep the slide-over uplift tab and the evidence upload while taking the
current board, dashboard, and vendor changes from main.
2026-09-24 15:17:34 -03:00

208 lines
8.4 KiB
TypeScript

import type {
VendorCompanyRoster,
VendorPreferredContact,
VendorRosterBlockedWorkOrder,
VendorRosterConflict,
VendorRosterConflictKind,
VendorRosterTechnician,
} from "@/domain/vendors/types/vendor";
import { toCanonicalPhone } from "@/domain/vendors/mappers/vendor-mapper";
const PREFERRED_CONTACT_VALUES = new Set<VendorPreferredContact>(["Phone", "Email", "Text"]);
function asRecord(raw: unknown): Record<string, unknown> {
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
}
function readString(record: Record<string, unknown>, ...keys: string[]): string {
for (const key of keys) {
const value = record[key];
if (typeof value === "string") return value;
if (typeof value === "number") return String(value);
}
return "";
}
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
for (const key of keys) {
const value = record[key];
if (typeof value === "boolean") return value;
}
return true;
}
function readPreferredContact(record: Record<string, unknown>): VendorPreferredContact | undefined {
const raw = record.preferredContact ?? record.PreferredContact;
if (typeof raw === "string") {
const normalized = raw.trim();
if (PREFERRED_CONTACT_VALUES.has(normalized as VendorPreferredContact)) {
return normalized as VendorPreferredContact;
}
const lowered = normalized.toLowerCase();
if (lowered === "phone" || lowered === "call") return "Phone";
if (lowered === "email" || lowered === "e-mail") return "Email";
if (lowered === "text" || lowered === "sms") return "Text";
}
return undefined;
}
function readId(record: Record<string, unknown>, ...keys: string[]): string | number | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.trim() !== "") return value;
if (typeof value === "number" && Number.isFinite(value)) return value;
}
return undefined;
}
function readCompanyId(record: Record<string, unknown>): string | number | null {
const value = readId(record, "companyId", "CompanyId");
return value ?? null;
}
export function mapRosterTechnician(raw: unknown): VendorRosterTechnician {
const item = asRecord(raw);
return {
id: readId(item, "id", "Id"),
contactName: readString(item, "contactName", "ContactName"),
phone: readString(item, "phone", "Phone"),
email: readString(item, "email", "Email"),
preferredContact: readPreferredContact(item),
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
isActive: readBool(item, "isActive", "IsActive"),
totalJobs: Number(item.totalJobs ?? item.TotalJobs ?? 0),
};
}
export function mapRosterTechnicians(raw: unknown): VendorRosterTechnician[] {
return Array.isArray(raw) ? raw.map(mapRosterTechnician) : [];
}
export function mapVendorCompanyRoster(raw: unknown): VendorCompanyRoster {
const item = asRecord(raw);
return {
companyId: readCompanyId(item),
rowVersion: readString(item, "rowVersion", "RowVersion"),
name: readString(item, "name", "Name", "companyName", "CompanyName"),
companyPhone: readString(item, "companyPhone", "CompanyPhone"),
email: readString(item, "email", "Email"),
address: readString(item, "address", "Address"),
city: readString(item, "city", "City"),
state: readString(item, "state", "State"),
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
notes: readString(item, "notes", "Notes"),
areaId: readString(item, "areaId", "AreaId"),
areaName: readString(item, "areaName", "AreaName"),
technicians: mapRosterTechnicians(item.technicians ?? item.Technicians),
};
}
/** Form Area ids are strings; the API takes a positive integer id or null (Unassigned). */
export function toBackendAreaId(value: unknown): number | null {
const parsed = Number(typeof value === "string" ? value.trim() : value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
export function mapRosterTechnicianToBackend(technician: unknown): Record<string, unknown> {
const item = asRecord(technician);
const payload: Record<string, unknown> = {
contactName: readString(item, "contactName", "ContactName"),
phone: toCanonicalPhone(item.phone ?? item.Phone),
email: readString(item, "email", "Email"),
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
isActive: readBool(item, "isActive", "IsActive"),
};
const preferredContact = readPreferredContact(item);
if (preferredContact) payload.preferredContact = preferredContact;
const id = readId(item, "id", "Id");
if (id !== undefined) payload.id = id;
return payload;
}
export function mapVendorRosterToBackend(values: unknown): Record<string, unknown> {
const item = asRecord(values);
const techniciansRaw = item.technicians ?? item.Technicians;
const payload: Record<string, unknown> = {
name: readString(item, "name", "Name", "companyName", "CompanyName"),
companyPhone: toCanonicalPhone(item.companyPhone ?? item.CompanyPhone),
email: readString(item, "email", "Email"),
address: readString(item, "address", "Address"),
city: readString(item, "city", "City"),
state: readString(item, "state", "State"),
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
// why: The stored Maps URL is deprecated: reads keep it for legacy records, but roster writes must not
// carry it back to the backend.
notes: readString(item, "notes", "Notes"),
technicians: Array.isArray(techniciansRaw)
? techniciansRaw.map(mapRosterTechnicianToBackend)
: [],
};
// why: The form always round-trips its Area (id or null = Unassigned). Callers without an Area value
// omit it, which the API treats as "leave unchanged".
if ("areaId" in item || "AreaId" in item) {
payload.areaId = toBackendAreaId(item.areaId ?? item.AreaId);
}
const rowVersion = readString(item, "rowVersion", "RowVersion");
if (rowVersion) payload.rowVersion = rowVersion;
return payload;
}
export interface VendorRosterAdditivePatch {
rowVersion: string;
addTechnicians: unknown[];
companyFields?: Record<string, string>;
}
export function mapVendorRosterAdditivePatchToBackend(
patch: VendorRosterAdditivePatch,
): Record<string, unknown> {
const item = asRecord(patch);
const payload: Record<string, unknown> = {
rowVersion: readString(item, "rowVersion", "RowVersion"),
addTechnicians: patch.addTechnicians.map((technician) => {
const mapped = mapRosterTechnicianToBackend(technician);
delete mapped.id;
return mapped;
}),
};
if (patch.companyFields) {
// SH-250: match the reconcile write path, which canonicalizes companyPhone.
// Copying companyFields verbatim let additive patches send a different phone
// shape than PUT for the same user input.
const companyFields: Record<string, unknown> = { ...patch.companyFields };
if (companyFields.companyPhone !== undefined)
companyFields.companyPhone = toCanonicalPhone(companyFields.companyPhone);
// why: An edited Area is sent as an id or an explicit null (Unassigned).
if (companyFields.areaId !== undefined)
companyFields.areaId = toBackendAreaId(companyFields.areaId);
payload.companyFields = companyFields;
}
return payload;
}
function mapBlockedWorkOrder(raw: unknown): VendorRosterBlockedWorkOrder {
const item = asRecord(raw);
return {
workOrderId: readId(item, "workOrderId", "WorkOrderId", "id", "Id") ?? "",
workOrderNumber: readString(item, "workOrderNumber", "WorkOrderNumber"),
workOrderTitle: readString(item, "workOrderTitle", "WorkOrderTitle", "title", "Title"),
status: readString(item, "status", "Status"),
scheduledDate: readString(item, "scheduledDate", "ScheduledDate") || undefined,
};
}
export function mapRosterConflict(raw: unknown, fallbackMessage: string): VendorRosterConflict {
const item = asRecord(raw);
const blockedRaw = item.blockedWorkOrders ?? item.BlockedWorkOrders ?? item.openWorkOrders;
const blockedWorkOrders = Array.isArray(blockedRaw) ? blockedRaw.map(mapBlockedWorkOrder) : [];
const code = readString(item, "code", "Code");
const kind: VendorRosterConflictKind =
code === "duplicate_vendor_company_name"
? "duplicate"
: blockedWorkOrders.length > 0
? "open-work-orders"
: "stale";
const message = readString(item, "message", "Message") || fallbackMessage;
return { kind, message, blockedWorkOrders };
}