feat: add time entry manager, controller, and API routes

This commit is contained in:
2026-04-21 00:52:35 +00:00
parent 38654601c9
commit a55850e2c1
39 changed files with 4700 additions and 440 deletions
@@ -0,0 +1,48 @@
import { SoActivityNotes as CwSoActivityNotes } from "../../generated/prisma/client";
import { Translation, skipRow } from "./types";
import { TranslationContext } from "./context";
type ApiActivityNotesRecord = {
id: number;
notes: string;
activityId: number | null;
internalAnalysisFlag: boolean;
enteredById: string | null;
updatedById: string | null;
createdAt: Date;
updatedAt: Date;
};
export const activityNotesTranslation: Translation<
CwSoActivityNotes,
ApiActivityNotesRecord,
TranslationContext
> = {
values: [
{ from: "soActivityNotesRecId", to: "id" },
{
from: "notes",
to: "notes",
process: (value: string) => value,
},
{
from: "soActivityRecId",
to: "activityId",
process: (value: number, context: TranslationContext) => {
if (!context.activityIds.has(value)) {
skipRow(`Activity ${value} not found in API`);
}
return value;
},
},
{
from: "internalAnalysisFlag",
to: "internalAnalysisFlag",
process: (value) => Boolean(value),
},
{ from: "enteredBy", to: "enteredById" },
{ from: "updatedBy", to: "updatedById" },
{ from: "dateCreatedUtc", to: "createdAt" },
{ from: "lastUpdateUtc", to: "updatedAt" },
],
};
@@ -0,0 +1,55 @@
import { SoActStatus as CwSoActStatus } from "../../generated/prisma/client";
import { Translation } from "./types";
type ApiActivityStatusRecord = {
id: number;
name: string;
description: string | null;
closedFlag: boolean;
inactiveFlag: boolean;
defaultFlag: boolean;
spawnFollowupFlag: boolean;
createdById: string | null;
updatedById: string | null;
createdAt: Date;
updatedAt: Date;
};
export const activityStatusTranslation: Translation<
CwSoActStatus,
ApiActivityStatusRecord
> = {
values: [
{ from: "soActStatusRecId", to: "id" },
{
from: "description",
to: "name",
process: (value) => (value ? value : "Unknown Status"),
},
{ from: "description", to: "description" },
{
from: "closedFlag",
to: "closedFlag",
process: (value) => Boolean(value),
},
{
from: "inactiveFlag",
to: "inactiveFlag",
process: (value) => Boolean(value),
},
{
from: "defaultFlag",
to: "defaultFlag",
process: (value) => Boolean(value),
},
{
from: "spawnFollowupFlag",
to: "spawnFollowupFlag",
process: (value) => Boolean(value),
},
{ from: "enteredBy", to: "createdById" },
{ from: "updatedBy", to: "updatedById" },
{ from: "dateEnteredUtc", to: "createdAt" },
{ from: "lastUpdateUtc", to: "updatedAt" },
],
};
+73
View File
@@ -0,0 +1,73 @@
import { SoActivityType as CwSoActivityType } from "../../generated/prisma/client";
import { Translation } from "./types";
type ApiActivityTypeRecord = {
id: number;
name: string;
description: string;
inactiveFlag: boolean;
historyFlag: boolean;
defaultFlag: boolean;
importFlag: boolean;
emailFlag: boolean;
memoFlag: boolean;
pointsValue: number | null;
createdById: string | null;
updatedById: string | null;
createdAt: Date;
updatedAt: Date;
};
export const activityTypeTranslation: Translation<
CwSoActivityType,
ApiActivityTypeRecord
> = {
values: [
{ from: "soActivityTypeRecId", to: "id" },
{
from: "soActivityTypeId",
to: "name",
process: (value) => (value ? value : ""),
},
{
from: "description",
to: "description",
process: (value) => (value ? value : ""),
},
{
from: "inactiveFlag",
to: "inactiveFlag",
process: (value) => Boolean(value),
},
{
from: "historyFlag",
to: "historyFlag",
process: (value) => Boolean(value),
},
{
from: "defaultFlag",
to: "defaultFlag",
process: (value) => Boolean(value),
},
{
from: "importFlag",
to: "importFlag",
process: (value) => Boolean(value),
},
{
from: "emailFlag",
to: "emailFlag",
process: (value) => Boolean(value),
},
{
from: "memoFlag",
to: "memoFlag",
process: (value) => Boolean(value ?? false),
},
{ from: "pointsValue", to: "pointsValue" },
{ from: "enteredBy", to: "createdById" },
{ from: "updatedBy", to: "updatedById" },
{ from: "dateEnteredUtc", to: "createdAt" },
{ from: "lastUpdateUtc", to: "updatedAt" },
],
};
+147
View File
@@ -0,0 +1,147 @@
import { SoActivity as CwSoActivity } from "../../generated/prisma/client";
import { Translation, skipRow } from "./types";
import { TranslationContext } from "./context";
type ApiActivityRecord = {
id: number;
subject: string;
startTime: Date;
endTime: Date;
assignToId: string;
assignedById: string;
enteredBy: string;
automated: boolean;
closedFlag: boolean;
notifyCompleteFlag: boolean;
notificationSentFlat: boolean;
opportunityId: string | null;
serviceTicketId: string | null;
contactId: number | null;
companyId: number | null;
activityTypeId: number | null;
activityStatusId: number | null;
createdById: string | null;
updatedById: string | null;
closedById: string | null;
closedAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
export const activityTranslation: Translation<
CwSoActivity,
ApiActivityRecord,
TranslationContext
> = {
values: [
{ from: "soActivityRecId", to: "id" },
{
from: "subject",
to: "subject",
process: (value) => (value ? value : ""),
},
{
from: "dateTimeStart",
to: "startTime",
process: (value) => {
if (!value) skipRow("Missing dateTimeStart");
return value as Date;
},
},
{
from: "dateTimeEnd",
to: "endTime",
process: (value) => {
if (!value) skipRow("Missing dateTimeEnd");
return value as Date;
},
},
{
from: "assignTo",
to: "assignToId",
},
{
from: "assignedBy",
to: "assignedById",
},
{
from: "enteredBy",
to: "enteredBy",
},
{
from: "automated",
to: "automated",
process: (value) => Boolean(value),
},
{
from: "closeFlag",
to: "closedFlag",
process: (value) => Boolean(value),
},
{
from: "notifyCompleteFlag",
to: "notifyCompleteFlag",
process: (value) => Boolean(value),
},
{
from: "notificationSentFlag",
to: "notificationSentFlat",
process: (value) => Boolean(value),
},
{
// opportunityId is a String? field with no @relation — store as null
// until a uid-mapping approach is established
from: "opportunityRecId",
to: "opportunityId",
process: () => null,
},
{
// serviceTicketId is a String? field with no @relation — store as null
// until a uid-mapping approach is established
from: "srServiceRecId",
to: "serviceTicketId",
process: () => null,
},
{
from: "contactRecId",
to: "contactId",
process: (value: number | null, context: TranslationContext) => {
if (!value) return null;
if (!context.contactIds.has(value)) return null;
return value;
},
},
{
from: "companyRecId",
to: "companyId",
process: (value: number | null, context: TranslationContext) => {
if (!value) return null;
if (!context.companyIds.has(value)) return null;
return value;
},
},
{
from: "soActivityTypeRecId",
to: "activityTypeId",
process: (value: number | null, context: TranslationContext) => {
if (!value) return null;
if (!context.activityTypeIds.has(value)) return null;
return value;
},
},
{
from: "soActStatusRecId",
to: "activityStatusId",
process: (value: number, context: TranslationContext) => {
if (!context.activityStatusIds.has(value)) return null;
return value;
},
},
{ from: "enteredBy", to: "createdById" },
{ from: "updatedBy", to: "updatedById" },
{ from: "closedBy", to: "closedById" },
{ from: "dateClosedUtc", to: "closedAt" },
{ from: "dateEnteredUtc", to: "createdAt" },
{ from: "lastUpdatedUTC", to: "updatedAt" },
],
};
+20
View File
@@ -73,6 +73,21 @@ export interface TranslationContext {
// Set of API TaxCode.id values for FK validation
taxCodeIds: Set<number>;
// Set of API ActivityType.id values for FK validation
activityTypeIds: Set<number>;
// Set of API ActivityStatus.id values for FK validation
activityStatusIds: Set<number>;
// Set of API Activity.id values for FK validation (used by ActivityNotes)
activityIds: Set<number>;
// Set of API TimeEntryChargeCode.id values for FK validation
timeEntryChargeCodeIds: Set<number>;
// Map: CW TE_Status_ID (smallint) -> API TimeEntryStatus.id (rec ID)
timeEntryStatusIdByTeStatusId: Map<number, number>;
}
/**
@@ -103,5 +118,10 @@ export function createTranslationContext(): TranslationContext {
scheduleTypeIds: new Set(),
scheduleSpanIds: new Set(),
taxCodeIds: new Set(),
activityTypeIds: new Set(),
activityStatusIds: new Set(),
activityIds: new Set(),
timeEntryChargeCodeIds: new Set(),
timeEntryStatusIdByTeStatusId: new Map(),
};
}
@@ -0,0 +1,39 @@
import { MemberType as CwMemberType } from "../../generated/prisma/client";
import { Translation } from "./types";
type ApiCwMemberTypeRecord = {
id: number;
description: string | null;
inactiveFlag: boolean;
createdById: string | null;
updatedById: string | null;
createdAt: Date;
updatedAt: Date;
};
export const cwMemberTypeTranslation: Translation<
CwMemberType,
ApiCwMemberTypeRecord
> = {
values: [
{ from: "memberTypeRecId", to: "id" },
{ from: "description", to: "description" },
{
from: "inactiveFlag",
to: "inactiveFlag",
process: (value) => Boolean(value),
},
{ from: "enteredBy", to: "createdById" },
{ from: "updatedBy", to: "updatedById" },
{
from: "dateEnteredUtc",
to: "createdAt",
process: (value) => (value as Date | null) ?? new Date(0),
},
{
from: "lastUpdateUtc",
to: "updatedAt",
process: (value) => (value as Date | null) ?? new Date(0),
},
],
};
@@ -0,0 +1,51 @@
import { ActivityClass as CwActivityClass } from "../../generated/prisma/client";
import { Translation } from "./types";
type ApiTimeActivityClassRecord = {
id: number;
description: string | null;
hourlyRate: number | null;
inactiveFlag: boolean;
taxExemptFlag: boolean;
createdById: string | null;
updatedById: string | null;
createdAt: Date;
updatedAt: Date;
};
export const timeActivityClassTranslation: Translation<
CwActivityClass,
ApiTimeActivityClassRecord
> = {
values: [
{ from: "activityClassRecId", to: "id" },
{ from: "description", to: "description" },
{
from: "hourlyRate",
to: "hourlyRate",
process: (value) => (value != null ? Number(value) : null),
},
{
from: "inactiveFlag",
to: "inactiveFlag",
process: (value) => Boolean(value),
},
{
from: "taxExemptFlag",
to: "taxExemptFlag",
process: (value) => Boolean(value),
},
{ from: "enteredBy", to: "createdById" },
{ from: "updatedBy", to: "updatedById" },
{
from: "dateEnteredUtc",
to: "createdAt",
process: (value) => (value as Date | null) ?? new Date(0),
},
{
from: "lastUpdatedUtc",
to: "updatedAt",
process: (value) => (value as Date | null) ?? new Date(0),
},
],
};
@@ -0,0 +1,93 @@
import { ActivityType as CwActivityType } from "../../generated/prisma/client";
import { Translation } from "./types";
type ApiTimeActivityTypeRecord = {
id: number;
description: string | null;
minHours: number | null;
maxHours: number | null;
rate: number | null;
costMultiplier: number;
inactiveFlag: boolean;
invoiceFlag: boolean;
billableFlag: boolean;
utilizationFlag: boolean;
defaultFlag: boolean;
multiplierFlag: boolean;
createdById: string | null;
updatedById: string | null;
createdAt: Date;
updatedAt: Date;
};
export const timeActivityTypeTranslation: Translation<
CwActivityType,
ApiTimeActivityTypeRecord
> = {
values: [
{ from: "activityTypeRecId", to: "id" },
{ from: "description", to: "description" },
{
from: "hoursMin",
to: "minHours",
process: (value) => (value != null ? Number(value) : null),
},
{
from: "hoursMax",
to: "maxHours",
process: (value) => (value != null ? Number(value) : null),
},
{
from: "rate",
to: "rate",
process: (value) => (value != null ? Number(value) : null),
},
{
from: "costMultiplier",
to: "costMultiplier",
process: (value) => (value != null ? Number(value) : 1),
},
{
from: "inactiveFlag",
to: "inactiveFlag",
process: (value) => Boolean(value),
},
{
from: "invoiceFlag",
to: "invoiceFlag",
process: (value) => Boolean(value),
},
{
from: "billableFlag",
to: "billableFlag",
process: (value) => Boolean(value),
},
{
from: "utilizationFlag",
to: "utilizationFlag",
process: (value) => Boolean(value),
},
{
from: "defaultFlag",
to: "defaultFlag",
process: (value) => Boolean(value ?? false),
},
{
from: "multiplierFlag",
to: "multiplierFlag",
process: (value) => Boolean(value),
},
{ from: "enteredBy", to: "createdById" },
{ from: "updatedBy", to: "updatedById" },
{
from: "dateEnteredUtc",
to: "createdAt",
process: (value) => (value as Date | null) ?? new Date(0),
},
{
from: "lastUpdatedUtc",
to: "updatedAt",
process: (value) => (value as Date | null) ?? new Date(0),
},
],
};
@@ -0,0 +1,59 @@
import { TeChargeCode as CwTeChargeCode } from "../../generated/prisma/client";
import { Translation } from "./types";
type ApiTimeEntryChargeCodeRecord = {
id: number;
chargeCodeId: number;
description: string | null;
expenseFlag: boolean;
timeFlag: boolean;
billableFlag: boolean;
invoiceFlag: boolean;
createdById: string | null;
updatedById: string | null;
createdAt: Date;
updatedAt: Date;
};
export const timeEntryChargeCodeTranslation: Translation<
CwTeChargeCode,
ApiTimeEntryChargeCodeRecord
> = {
values: [
{ from: "teChargeCodeRecId", to: "id" },
{ from: "teChargeCodeRecId", to: "chargeCodeId" },
{ from: "description", to: "description" },
{
from: "expenseFlag",
to: "expenseFlag",
process: (value) => Boolean(value),
},
{
from: "timeFlag",
to: "timeFlag",
process: (value) => Boolean(value),
},
{
from: "billableFlag",
to: "billableFlag",
process: (value) => Boolean(value),
},
{
from: "invoiceFlag",
to: "invoiceFlag",
process: (value) => Boolean(value ?? false),
},
{ from: "enteredBy", to: "createdById" },
{ from: "updatedBy", to: "updatedById" },
{
from: "dateEnteredUtc",
to: "createdAt",
process: (value) => (value as Date | null) ?? new Date(0),
},
{
from: "lastUpdateUtc",
to: "updatedAt",
process: (value) => (value as Date | null) ?? new Date(0),
},
],
};
@@ -0,0 +1,33 @@
import { TeStatus as CwTeStatus } from "../../generated/prisma/client";
import { Translation } from "./types";
type ApiTimeEntryStatusRecord = {
id: number;
statusId: number;
description: string | null;
action: string | null;
createdAt: Date;
updatedAt: Date;
};
export const timeEntryStatusTranslation: Translation<
CwTeStatus,
ApiTimeEntryStatusRecord
> = {
values: [
{ from: "teStatusRecId", to: "id" },
{ from: "teStatusId", to: "statusId" },
{ from: "description", to: "description" },
{ from: "action", to: "action" },
{
from: "lastUpdatedUtc",
to: "createdAt",
process: (value) => (value as Date | null) ?? new Date(0),
},
{
from: "lastUpdatedUtc",
to: "updatedAt",
process: (value) => (value as Date | null) ?? new Date(0),
},
],
};
+208
View File
@@ -0,0 +1,208 @@
import { TimeEntry as CwTimeEntry } from "../../generated/prisma/client";
import { Translation, skipRow } from "./types";
import { TranslationContext } from "./context";
type ApiTimeEntryRecord = {
id: number;
uid: string;
memberId: string | null;
companyId: number;
serviceTicketId: number | null;
activityId: number | null;
chargeCodeId: number | null;
statusId: number | null;
contactId: number | null;
dateStart: Date | null;
timeStart: Date | null;
timeEnd: Date | null;
notes: string | null;
notesMd: string | null;
internalNote: string | null;
billableHours: number | null;
actualHours: number | null;
invoicedHours: number | null;
deductedHours: number | null;
hourlyRate: number | null;
effectiveRate: number | null;
issueFlag: boolean;
mergedFlag: boolean;
invoiceFlag: boolean;
billableFlag: boolean;
documentFlag: boolean;
teProblemFlag: boolean;
teResolutionFlag: boolean;
teInternalAnalysisFlag: boolean;
chargeToRecId: number | null;
chargeToType: string | null;
createdById: string | null;
updatedById: string | null;
originalAuthorId: string | null;
createdAt: Date;
updatedAt: Date;
};
export const timeEntryTranslation: Translation<
CwTimeEntry,
ApiTimeEntryRecord,
TranslationContext
> = {
values: [
{ from: "timeRecId", to: "id" },
{
from: "mobileGuid",
to: "uid",
process: (value: string) => value,
},
{ from: "memberId", to: "memberId" },
{
from: "companyRecId",
to: "companyId",
process: (value: number, context: TranslationContext) => {
if (!context.companyIds.has(value)) return skipRow(`company ${value} not found`);
return value;
},
},
{
from: "srServiceRecId",
to: "serviceTicketId",
process: (value: number | null, context: TranslationContext) => {
if (!value) return null;
if (!context.serviceTicketIds.has(value)) return null;
return value;
},
},
{
from: "soActivityRecId",
to: "activityId",
process: (value: number | null, context: TranslationContext) => {
if (!value) return null;
if (!context.activityIds.has(value)) return null;
return value;
},
},
{
from: "teChargeCodeRecId",
to: "chargeCodeId",
process: (value: number | null, context: TranslationContext) => {
if (!value) return null;
if (!context.timeEntryChargeCodeIds.has(value)) return null;
return value;
},
},
{
from: "teStatusId",
to: "statusId",
process: (value: number, context: TranslationContext) => {
return context.timeEntryStatusIdByTeStatusId.get(value) ?? null;
},
},
{
from: "contactRecId",
to: "contactId",
process: (value: number | null, context: TranslationContext) => {
if (!value) return null;
if (!context.contactIds.has(value)) return null;
return value;
},
},
{ from: "dateStart", to: "dateStart" },
{ from: "timeStart", to: "timeStart" },
{ from: "timeEnd", to: "timeEnd" },
{
from: "notes",
to: "notes",
process: (value: string | null) => value ?? null,
},
{
from: "notesMarkdown",
to: "notesMd",
process: (value: string | null) => value ?? null,
},
{ from: "internalNote", to: "internalNote" },
{
from: "hoursBill",
to: "billableHours",
process: (value) => (value != null ? Number(value) : null),
},
{
from: "hoursActual",
to: "actualHours",
process: (value) => (value != null ? Number(value) : null),
},
{
from: "hoursInvoiced",
to: "invoicedHours",
process: (value) => (value != null ? Number(value) : null),
},
{
from: "hoursDeduct",
to: "deductedHours",
process: (value) => (value != null ? Number(value) : null),
},
{
from: "hourlyRate",
to: "hourlyRate",
process: (value) => (value != null ? Number(value) : null),
},
{
from: "effectiveRate",
to: "effectiveRate",
process: (value) => (value != null ? Number(value) : null),
},
{
from: "issueFlag",
to: "issueFlag",
process: (value) => Boolean(value),
},
{
from: "mergedFlag",
to: "mergedFlag",
process: (value) => Boolean(value),
},
{
from: "invoiceFlag",
to: "invoiceFlag",
process: (value) => Boolean(value),
},
{
from: "billableFlag",
to: "billableFlag",
process: (value) => Boolean(value),
},
{
from: "documentFlag",
to: "documentFlag",
process: (value) => Boolean(value),
},
{
from: "teProblemFlag",
to: "teProblemFlag",
process: (value) => Boolean(value),
},
{
from: "teResolutionFlag",
to: "teResolutionFlag",
process: (value) => Boolean(value),
},
{
from: "teInternalAnalysisFlag",
to: "teInternalAnalysisFlag",
process: (value) => Boolean(value),
},
{ from: "chargeToRecId", to: "chargeToRecId" },
{ from: "chargeToType", to: "chargeToType" },
{ from: "enteredBy", to: "createdById" },
{ from: "updatedBy", to: "updatedById" },
{ from: "originalAuthor", to: "originalAuthorId" },
{
from: "dateEnteredUtc",
to: "createdAt",
process: (value) => (value as Date | null) ?? new Date(0),
},
{
from: "lastUpdateUtc",
to: "updatedAt",
process: (value) => (value as Date | null) ?? new Date(0),
},
],
};