feat: add time entry manager, controller, and API routes
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import * as timeEntryRoutes from "../time-entries";
|
||||
|
||||
const timeEntryRouter = new Hono();
|
||||
Object.values(timeEntryRoutes).map((r) => timeEntryRouter.route("/", r));
|
||||
|
||||
export default timeEntryRouter;
|
||||
@@ -3,6 +3,7 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { timeEntries } from "../../../../../managers/timeEntries";
|
||||
import { activityCw } from "../../../../../modules/cw-utils/activities/activities";
|
||||
import { ActivityController } from "../../../../../controllers/ActivityController";
|
||||
import { OptimaType } from "../../../../../workflows/wf.opportunity";
|
||||
@@ -31,6 +32,9 @@ const QUOTE_ID_FIELD_ID = 48;
|
||||
/** Close Date custom field ID (matches wf.opportunity.ts CLOSE_DATE_FIELD_ID). */
|
||||
const CLOSE_DATE_FIELD_ID = 49;
|
||||
|
||||
/** Parent_Activity custom field ID. */
|
||||
const PARENT_ACTIVITY_FIELD_ID = 50;
|
||||
|
||||
/**
|
||||
* Extract the Optima_Type value from a CW activity's custom fields.
|
||||
* Returns the string value if present, or null.
|
||||
@@ -70,6 +74,22 @@ function extractCloseDate(
|
||||
return field.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the Parent_Activity custom field value from a CW activity.
|
||||
* Returns the numeric activity ID or null.
|
||||
*/
|
||||
function extractParentActivityId(
|
||||
customFields: { id: number; value: unknown }[] | undefined,
|
||||
): number | null {
|
||||
if (!customFields) return null;
|
||||
const field = customFields.find(
|
||||
(f) => f.id === PARENT_ACTIVITY_FIELD_ID || (f as any).caption === "Parent_Activity",
|
||||
);
|
||||
if (!field?.value) return null;
|
||||
const parsed = parseInt(String(field.value), 10);
|
||||
return isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ROUTE
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -96,6 +116,7 @@ export default createRoute(
|
||||
activity: ReturnType<ActivityController["toJson"]>;
|
||||
optimaType: string;
|
||||
quoteId: string | null;
|
||||
parentActivityId: number | null;
|
||||
closed: boolean;
|
||||
closedAt: string | null;
|
||||
}[] = [];
|
||||
@@ -109,6 +130,7 @@ export default createRoute(
|
||||
if (filterType && optimaType !== filterType) continue;
|
||||
|
||||
const quoteId = extractQuoteId(raw.customFields);
|
||||
const parentActivityId = extractParentActivityId(raw.customFields);
|
||||
const closed = raw.status?.id === 2;
|
||||
const closedAt = extractCloseDate(raw.customFields);
|
||||
|
||||
@@ -116,6 +138,7 @@ export default createRoute(
|
||||
activity: json,
|
||||
optimaType,
|
||||
quoteId,
|
||||
parentActivityId,
|
||||
closed,
|
||||
closedAt,
|
||||
});
|
||||
@@ -132,13 +155,26 @@ export default createRoute(
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
// Attach time entries for each activity in parallel
|
||||
const activitiesWithTimeEntries = await Promise.all(
|
||||
workflowActivities.map(async (item) => {
|
||||
const entries = await timeEntries.fetchByActivity(
|
||||
item.activity.cwActivityId,
|
||||
);
|
||||
return {
|
||||
...item,
|
||||
timeEntries: entries.map((e) => e.toJson()),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Workflow history fetched successfully.",
|
||||
{
|
||||
opportunityId: opportunity.id,
|
||||
cwOpportunityId: opportunity.cwOpportunityId,
|
||||
totalActivities: workflowActivities.length,
|
||||
activities: workflowActivities,
|
||||
totalActivities: activitiesWithTimeEntries.length,
|
||||
activities: activitiesWithTimeEntries,
|
||||
},
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
|
||||
@@ -16,6 +16,7 @@ import procurementRouter from "./routers/procurementRouter";
|
||||
import salesRouter from "./routers/salesRouter";
|
||||
import cwRouter from "./routers/cwRouter";
|
||||
import scheduleRouter from "./routers/scheduleRouter";
|
||||
import timeEntryRouter from "./routers/timeEntryRouter";
|
||||
|
||||
const app = new Hono();
|
||||
const v1 = new Hono();
|
||||
@@ -73,6 +74,7 @@ v1.route("/procurement", procurementRouter);
|
||||
v1.route("/sales", salesRouter);
|
||||
v1.route("/cw", cwRouter);
|
||||
v1.route("/schedule", scheduleRouter);
|
||||
v1.route("/time-entry", timeEntryRouter);
|
||||
app.route("/v1", v1);
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { timeEntries } from "../../../managers/timeEntries";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
|
||||
/* GET /v1/time-entry/time-entries/:identifier */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/time-entries/:identifier"],
|
||||
async (c) => {
|
||||
const entry = await timeEntries.fetch(c.req.param("identifier"));
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Time entry fetched successfully!",
|
||||
entry.toJson(),
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["time-entry.fetch"] }),
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { timeEntries } from "../../managers/timeEntries";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
|
||||
/* GET /v1/time-entry/count */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/count"],
|
||||
async (c) => {
|
||||
const count = await timeEntries.count();
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Time entry count fetched successfully!",
|
||||
{ count },
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["time-entry.fetch.many"] }),
|
||||
);
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { timeEntries } from "../../managers/timeEntries";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
|
||||
/* GET /v1/time-entry/time-entries?page=&rpp=&search= */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/time-entries"],
|
||||
async (c) => {
|
||||
const page = new Number(c.req.query("page") ?? 1) as number;
|
||||
const rpp = new Number(c.req.query("rpp") ?? 30) as number;
|
||||
const search = c.req.query("search");
|
||||
|
||||
const data = search
|
||||
? await timeEntries.search(search, page, rpp)
|
||||
: await timeEntries.fetchPages(page, rpp);
|
||||
|
||||
const totalRecords = search
|
||||
? (await timeEntries.search(search, 1, 999999)).length
|
||||
: await timeEntries.count();
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Time entries fetched successfully!",
|
||||
data.map((e) => e.toJson()),
|
||||
{
|
||||
pagination: {
|
||||
previousPage: page == 1 ? null : page - 1,
|
||||
currentPage: page,
|
||||
nextPage: page >= totalRecords / rpp ? null : page + 1,
|
||||
totalPages: Math.ceil(totalRecords / rpp),
|
||||
totalRecords,
|
||||
listedRecords: rpp,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["time-entry.fetch.many"] }),
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
import { default as fetchAll } from "./fetchAll";
|
||||
import { default as count } from "./count";
|
||||
import { default as fetch } from "./[identifier]/fetch";
|
||||
import { default as fetchByMember } from "./member/fetchByMember";
|
||||
import { default as fetchByTicket } from "./ticket/fetchByTicket";
|
||||
import { default as fetchMyTimeEntries } from "./me/fetchMyTimeEntries";
|
||||
|
||||
export { count, fetch, fetchAll, fetchByMember, fetchByTicket, fetchMyTimeEntries };
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { timeEntries } from "../../../managers/timeEntries";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
|
||||
/* GET /v1/time-entry/@me?start=<ISO>&end=<ISO> */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/@me"],
|
||||
async (c) => {
|
||||
const user = c.get("user");
|
||||
|
||||
if (!user?.cwIdentifier) {
|
||||
throw new GenericError({
|
||||
name: "BadRequest",
|
||||
message:
|
||||
"Your account is not linked to a ConnectWise member. Cannot fetch time entries.",
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const startParam = c.req.query("start");
|
||||
const endParam = c.req.query("end");
|
||||
|
||||
if (!startParam || !endParam) {
|
||||
throw new GenericError({
|
||||
name: "BadRequest",
|
||||
message:
|
||||
"Query params 'start' and 'end' are required (ISO 8601 date strings).",
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const startDate = new Date(startParam);
|
||||
const endDate = new Date(endParam);
|
||||
|
||||
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||
throw new GenericError({
|
||||
name: "BadRequest",
|
||||
message: "Invalid date format. Use ISO 8601 (e.g. 2026-04-01T00:00:00Z).",
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (startDate >= endDate) {
|
||||
throw new GenericError({
|
||||
name: "BadRequest",
|
||||
message: "'start' must be before 'end'.",
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const data = await timeEntries.fetchByDateRange(
|
||||
startDate,
|
||||
endDate,
|
||||
user.cwIdentifier,
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Time entries fetched successfully!",
|
||||
data.map((e) => e.toJson()),
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["time-entry.fetch"] }),
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { timeEntries } from "../../../managers/timeEntries";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
|
||||
/* GET /v1/time-entry/member/:memberId?page=&rpp= */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/member/:memberId"],
|
||||
async (c) => {
|
||||
const memberId = c.req.param("memberId");
|
||||
const page = new Number(c.req.query("page") ?? 1) as number;
|
||||
const rpp = new Number(c.req.query("rpp") ?? 30) as number;
|
||||
|
||||
const data = await timeEntries.fetchByMember(memberId, page, rpp);
|
||||
const totalRecords = await timeEntries.countByMember(memberId);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Time entries fetched successfully!",
|
||||
data.map((e) => e.toJson()),
|
||||
{
|
||||
pagination: {
|
||||
previousPage: page == 1 ? null : page - 1,
|
||||
currentPage: page,
|
||||
nextPage: page >= totalRecords / rpp ? null : page + 1,
|
||||
totalPages: Math.ceil(totalRecords / rpp),
|
||||
totalRecords,
|
||||
listedRecords: rpp,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["time-entry.fetch.many"] }),
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { timeEntries } from "../../../managers/timeEntries";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
|
||||
/* GET /v1/time-entry/ticket/:ticketId */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/ticket/:ticketId"],
|
||||
async (c) => {
|
||||
const ticketId = parseInt(c.req.param("ticketId"), 10);
|
||||
|
||||
const data = await timeEntries.fetchByTicket(ticketId);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Time entries fetched successfully!",
|
||||
data.map((e) => e.toJson()),
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["time-entry.fetch.many"] }),
|
||||
);
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
TimeEntry,
|
||||
TimeEntryStatus,
|
||||
TimeEntryChargeCode,
|
||||
Company,
|
||||
ServiceTicket,
|
||||
Activity,
|
||||
Contact,
|
||||
CorporateLocation,
|
||||
} from "../../generated/prisma/client";
|
||||
|
||||
type TimeEntryWithRelations = TimeEntry & {
|
||||
status?: TimeEntryStatus | null;
|
||||
chargeCode?: TimeEntryChargeCode | null;
|
||||
company?: Company | null;
|
||||
serviceTicket?: ServiceTicket | null;
|
||||
activity?: Activity | null;
|
||||
contact?: Contact | null;
|
||||
location?: CorporateLocation | null;
|
||||
};
|
||||
|
||||
export class TimeEntryController {
|
||||
public readonly id: number;
|
||||
public readonly uid: string;
|
||||
|
||||
private _data: TimeEntryWithRelations;
|
||||
|
||||
constructor(data: TimeEntryWithRelations) {
|
||||
this.id = data.id;
|
||||
this.uid = data.uid;
|
||||
this._data = data;
|
||||
}
|
||||
|
||||
public toJson() {
|
||||
const d = this._data;
|
||||
return {
|
||||
id: d.uid,
|
||||
cwId: d.id,
|
||||
memberId: d.memberId,
|
||||
|
||||
// ------ Time Fields ------
|
||||
dateStart: d.dateStart,
|
||||
timeStart: d.timeStart,
|
||||
timeEnd: d.timeEnd,
|
||||
|
||||
// ------ Notes ------
|
||||
notes: d.notes,
|
||||
notesMd: d.notesMd,
|
||||
internalNote: d.internalNote,
|
||||
|
||||
// ------ Hours ------
|
||||
billableHours: d.billableHours,
|
||||
actualHours: d.actualHours,
|
||||
invoicedHours: d.invoicedHours,
|
||||
deductedHours: d.deductedHours,
|
||||
|
||||
// ------ Rates ------
|
||||
hourlyRate: d.hourlyRate,
|
||||
effectiveRate: d.effectiveRate,
|
||||
|
||||
// ------ Flag Fields ------
|
||||
issueFlag: d.issueFlag,
|
||||
mergedFlag: d.mergedFlag,
|
||||
invoiceFlag: d.invoiceFlag,
|
||||
billableFlag: d.billableFlag,
|
||||
documentFlag: d.documentFlag,
|
||||
teProblemFlag: d.teProblemFlag,
|
||||
teResolutionFlag: d.teResolutionFlag,
|
||||
teInternalAnalysisFlag: d.teInternalAnalysisFlag,
|
||||
|
||||
// ------ Charge Info ------
|
||||
chargeToRecId: d.chargeToRecId,
|
||||
chargeToType: d.chargeToType,
|
||||
|
||||
// ------ Relations ------
|
||||
company: d.company
|
||||
? { id: d.company.uid, cwId: d.company.id, name: d.company.name }
|
||||
: null,
|
||||
serviceTicket: d.serviceTicket
|
||||
? {
|
||||
id: d.serviceTicket.uid,
|
||||
cwId: d.serviceTicket.id,
|
||||
summary: d.serviceTicket.summary,
|
||||
}
|
||||
: null,
|
||||
activity: d.activity
|
||||
? {
|
||||
id: d.activity.uid,
|
||||
cwId: d.activity.id,
|
||||
subject: d.activity.subject,
|
||||
}
|
||||
: null,
|
||||
contact: d.contact
|
||||
? {
|
||||
id: d.contact.uid,
|
||||
cwId: d.contact.id,
|
||||
name: `${d.contact.firstName} ${d.contact.lastName}`.trim(),
|
||||
}
|
||||
: null,
|
||||
location: d.location
|
||||
? { id: d.location.uid, cwId: d.location.id, name: d.location.name }
|
||||
: null,
|
||||
status: d.status
|
||||
? {
|
||||
id: d.status.uid,
|
||||
cwId: d.status.id,
|
||||
description: d.status.description,
|
||||
action: d.status.action,
|
||||
}
|
||||
: null,
|
||||
chargeCode: d.chargeCode
|
||||
? {
|
||||
id: d.chargeCode.uid,
|
||||
cwId: d.chargeCode.id,
|
||||
description: d.chargeCode.description,
|
||||
expenseFlag: d.chargeCode.expenseFlag,
|
||||
timeFlag: d.chargeCode.timeFlag,
|
||||
billableFlag: d.chargeCode.billableFlag,
|
||||
invoiceFlag: d.chargeCode.invoiceFlag,
|
||||
}
|
||||
: null,
|
||||
|
||||
// ------ Audit ------
|
||||
createdById: d.createdById,
|
||||
updatedById: d.updatedById,
|
||||
originalAuthorId: d.originalAuthorId,
|
||||
createdAt: d.createdAt,
|
||||
updatedAt: d.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { prisma } from "../constants";
|
||||
import { TimeEntryController } from "../controllers/TimeEntryController";
|
||||
|
||||
const timeEntryIncludes = {
|
||||
status: true,
|
||||
chargeCode: true,
|
||||
company: true,
|
||||
serviceTicket: true,
|
||||
activity: true,
|
||||
contact: true,
|
||||
location: true,
|
||||
} as const;
|
||||
|
||||
export const timeEntries = {
|
||||
async fetch(identifier: string | number): Promise<TimeEntryController> {
|
||||
const isNumeric =
|
||||
typeof identifier === "number" || /^\d+$/.test(String(identifier));
|
||||
|
||||
const entry = await prisma.timeEntry.findFirst({
|
||||
where: isNumeric
|
||||
? { id: Number(identifier) }
|
||||
: { uid: String(identifier) },
|
||||
include: timeEntryIncludes,
|
||||
});
|
||||
|
||||
if (!entry) throw new Error("Unknown time entry.");
|
||||
|
||||
return new TimeEntryController(entry);
|
||||
},
|
||||
|
||||
async count(): Promise<number> {
|
||||
return prisma.timeEntry.count();
|
||||
},
|
||||
|
||||
async fetchPages(page: number, rpp: number): Promise<TimeEntryController[]> {
|
||||
page = page.valueOf();
|
||||
rpp = rpp.valueOf();
|
||||
|
||||
const skip = (page > 1 ? page - 1 : 0) * rpp;
|
||||
const take = rpp ?? 30;
|
||||
|
||||
const data = await prisma.timeEntry.findMany({
|
||||
skip,
|
||||
take,
|
||||
include: timeEntryIncludes,
|
||||
orderBy: { dateStart: "desc" },
|
||||
});
|
||||
|
||||
return data.map((e) => new TimeEntryController(e));
|
||||
},
|
||||
|
||||
async search(
|
||||
query: string,
|
||||
page: number,
|
||||
rpp: number
|
||||
): Promise<TimeEntryController[]> {
|
||||
page = page.valueOf();
|
||||
rpp = rpp.valueOf();
|
||||
|
||||
const skip = (page > 1 ? page - 1 : 0) * rpp;
|
||||
const take = rpp ?? 30;
|
||||
|
||||
const numericQuery = parseInt(query, 10);
|
||||
|
||||
const data = await prisma.timeEntry.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ notes: { contains: query, mode: "insensitive" } },
|
||||
{ internalNote: { contains: query, mode: "insensitive" } },
|
||||
{ uid: { contains: query, mode: "insensitive" } },
|
||||
...(!isNaN(numericQuery) ? [{ id: numericQuery }] : []),
|
||||
],
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
include: timeEntryIncludes,
|
||||
orderBy: { dateStart: "desc" },
|
||||
});
|
||||
|
||||
return data.map((e) => new TimeEntryController(e));
|
||||
},
|
||||
|
||||
async fetchByMember(
|
||||
memberId: string,
|
||||
page: number,
|
||||
rpp: number
|
||||
): Promise<TimeEntryController[]> {
|
||||
page = page.valueOf();
|
||||
rpp = rpp.valueOf();
|
||||
|
||||
const skip = (page > 1 ? page - 1 : 0) * rpp;
|
||||
const take = rpp ?? 30;
|
||||
|
||||
const data = await prisma.timeEntry.findMany({
|
||||
where: { memberId },
|
||||
skip,
|
||||
take,
|
||||
include: timeEntryIncludes,
|
||||
orderBy: { dateStart: "desc" },
|
||||
});
|
||||
|
||||
return data.map((e) => new TimeEntryController(e));
|
||||
},
|
||||
|
||||
async countByMember(memberId: string): Promise<number> {
|
||||
return prisma.timeEntry.count({ where: { memberId } });
|
||||
},
|
||||
|
||||
async fetchByTicket(
|
||||
serviceTicketId: number
|
||||
): Promise<TimeEntryController[]> {
|
||||
const data = await prisma.timeEntry.findMany({
|
||||
where: { serviceTicketId },
|
||||
include: timeEntryIncludes,
|
||||
orderBy: { dateStart: "desc" },
|
||||
});
|
||||
|
||||
return data.map((e) => new TimeEntryController(e));
|
||||
},
|
||||
|
||||
async fetchByActivity(activityId: number): Promise<TimeEntryController[]> {
|
||||
const data = await prisma.timeEntry.findMany({
|
||||
where: { activityId },
|
||||
include: timeEntryIncludes,
|
||||
orderBy: { dateStart: "desc" },
|
||||
});
|
||||
|
||||
return data.map((e) => new TimeEntryController(e));
|
||||
},
|
||||
|
||||
async fetchByDateRange(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
memberId?: string
|
||||
): Promise<TimeEntryController[]> {
|
||||
const data = await prisma.timeEntry.findMany({
|
||||
where: {
|
||||
dateStart: { gte: startDate, lte: endDate },
|
||||
...(memberId ? { memberId } : {}),
|
||||
},
|
||||
include: timeEntryIncludes,
|
||||
orderBy: { dateStart: "asc" },
|
||||
});
|
||||
|
||||
return data.map((e) => new TimeEntryController(e));
|
||||
},
|
||||
};
|
||||
@@ -1616,6 +1616,32 @@ export async function createScheduleEntry(
|
||||
: undefined;
|
||||
const dateEnd = payload.endTime ? toCwDateTime(payload.endTime) : undefined;
|
||||
|
||||
// Find the currently open workflow activity (OpportunitySetup, OpportunityReview,
|
||||
// or Revision) to use as the parent for this schedule entry.
|
||||
let parentActivityCwId: number | null = null;
|
||||
try {
|
||||
const existingActivities = await activityCw.fetchByOpportunityDirect(
|
||||
opportunity.cwOpportunityId,
|
||||
);
|
||||
for (const raw of existingActivities) {
|
||||
if (raw.status?.id === 2) continue; // already closed
|
||||
const optimaField = raw.customFields?.find(
|
||||
(f: any) => f.id === OptimaType.FIELD_ID,
|
||||
);
|
||||
if (!optimaField?.value) continue;
|
||||
if (optimaField.value === OptimaType.ScheduleEntry) continue; // skip other schedule entries
|
||||
if (STAYS_OPEN_TYPES.has(optimaField.value as OptimaTypeValue)) {
|
||||
parentActivityCwId = raw.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-fatal — schedule entry will be created without a parent
|
||||
console.warn(
|
||||
`[Workflow:ScheduleEntry] Could not resolve parent activity: ${err}`,
|
||||
);
|
||||
}
|
||||
|
||||
const activity = await ActivityController.create({
|
||||
name: `[Schedule Entry] ${payload.activityTypeValue} — ${opportunity.name}`,
|
||||
type: { id: 3 }, // HistoricEntry
|
||||
@@ -1627,21 +1653,35 @@ export async function createScheduleEntry(
|
||||
...(dateEnd ? { dateEnd } : {}),
|
||||
});
|
||||
|
||||
// Set Optima_Type to Schedule Entry (stays open)
|
||||
// Build custom fields: always Optima_Type, plus Parent_Activity when resolved
|
||||
const customFields: any[] = [
|
||||
{
|
||||
id: OptimaType.FIELD_ID,
|
||||
caption: "Optima_Type",
|
||||
type: "Text",
|
||||
entryMethod: "List",
|
||||
numberOfDecimals: 0,
|
||||
value: OptimaType.ScheduleEntry,
|
||||
},
|
||||
];
|
||||
|
||||
if (parentActivityCwId != null) {
|
||||
customFields.push({
|
||||
id: PARENT_ACTIVITY_FIELD_ID,
|
||||
caption: "Parent_Activity",
|
||||
type: "Text",
|
||||
entryMethod: "EntryField",
|
||||
numberOfDecimals: 0,
|
||||
value: String(parentActivityCwId),
|
||||
});
|
||||
}
|
||||
|
||||
// Set Optima_Type (+ Parent_Activity) on the new schedule entry
|
||||
await activity.update([
|
||||
{
|
||||
op: "replace",
|
||||
path: "customFields",
|
||||
value: [
|
||||
{
|
||||
id: OptimaType.FIELD_ID,
|
||||
caption: "Optima_Type",
|
||||
type: "Text",
|
||||
entryMethod: "List",
|
||||
numberOfDecimals: 0,
|
||||
value: OptimaType.ScheduleEntry,
|
||||
},
|
||||
],
|
||||
value: customFields,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user