diff --git a/api/ql b/api/ql deleted file mode 100644 index 333a0b5..0000000 --- a/api/ql +++ /dev/null @@ -1,258 +0,0 @@ - - SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS - - Commands marked with * may be preceded by a number, _N. - Notes in parentheses indicate the behavior if _N is given. - A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K. - - h H Display this help. - q :q Q :Q ZZ Exit. - --------------------------------------------------------------------------- - - MMOOVVIINNGG - - e ^E j ^N CR * Forward one line (or _N lines). - y ^Y k ^K ^P * Backward one line (or _N lines). - f ^F ^V SPACE * Forward one window (or _N lines). - b ^B ESC-v * Backward one window (or _N lines). - z * Forward one window (and set window to _N). - w * Backward one window (and set window to _N). - ESC-SPACE * Forward one window, but don't stop at end-of-file. - d ^D * Forward one half-window (and set half-window to _N). - u ^U * Backward one half-window (and set half-window to _N). - ESC-) RightArrow * Right one half screen width (or _N positions). - ESC-( LeftArrow * Left one half screen width (or _N positions). - ESC-} ^RightArrow Right to last column displayed. - ESC-{ ^LeftArrow Left to first column. - F Forward forever; like "tail -f". - ESC-F Like F but stop when search pattern is found. - r ^R ^L Repaint screen. - R Repaint screen, discarding buffered input. - --------------------------------------------------- - Default "window" is the screen height. - Default "half-window" is half of the screen height. - --------------------------------------------------------------------------- - - SSEEAARRCCHHIINNGG - - /_p_a_t_t_e_r_n * Search forward for (_N-th) matching line. - ?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line. - n * Repeat previous search (for _N-th occurrence). - N * Repeat previous search in reverse direction. - ESC-n * Repeat previous search, spanning files. - ESC-N * Repeat previous search, reverse dir. & spanning files. - ESC-u Undo (toggle) search highlighting. - ESC-U Clear search highlighting. - &_p_a_t_t_e_r_n * Display only matching lines. - --------------------------------------------------- - A search pattern may begin with one or more of: - ^N or ! Search for NON-matching lines. - ^E or * Search multiple files (pass thru END OF FILE). - ^F or @ Start search at FIRST file (for /) or last file (for ?). - ^K Highlight matches, but don't move (KEEP position). - ^R Don't use REGULAR EXPRESSIONS. - ^W WRAP search if no match found. - --------------------------------------------------------------------------- - - JJUUMMPPIINNGG - - g < ESC-< * Go to first line in file (or line _N). - G > ESC-> * Go to last line in file (or line _N). - p % * Go to beginning of file (or _N percent into file). - t * Go to the (_N-th) next tag. - T * Go to the (_N-th) previous tag. - { ( [ * Find close bracket } ) ]. - } ) ] * Find open bracket { ( [. - ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>. - ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>. - --------------------------------------------------- - Each "find close bracket" command goes forward to the close bracket - matching the (_N-th) open bracket in the top line. - Each "find open bracket" command goes backward to the open bracket - matching the (_N-th) close bracket in the bottom line. - - m_<_l_e_t_t_e_r_> Mark the current top line with . - M_<_l_e_t_t_e_r_> Mark the current bottom line with . - '_<_l_e_t_t_e_r_> Go to a previously marked position. - '' Go to the previous position. - ^X^X Same as '. - ESC-M_<_l_e_t_t_e_r_> Clear a mark. - --------------------------------------------------- - A mark is any upper-case or lower-case letter. - Certain marks are predefined: - ^ means beginning of the file - $ means end of the file - --------------------------------------------------------------------------- - - CCHHAANNGGIINNGG FFIILLEESS - - :e [_f_i_l_e] Examine a new file. - ^X^V Same as :e. - :n * Examine the (_N-th) next file from the command line. - :p * Examine the (_N-th) previous file from the command line. - :x * Examine the first (or _N-th) file from the command line. - :d Delete the current file from the command line list. - = ^G :f Print current file name. - --------------------------------------------------------------------------- - - MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS - - -_<_f_l_a_g_> Toggle a command line option [see OPTIONS below]. - --_<_n_a_m_e_> Toggle a command line option, by name. - __<_f_l_a_g_> Display the setting of a command line option. - ___<_n_a_m_e_> Display the setting of an option, by name. - +_c_m_d Execute the less cmd each time a new file is examined. - - !_c_o_m_m_a_n_d Execute the shell command with $SHELL. - |XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command. - s _f_i_l_e Save input to a file. - v Edit the current file with $VISUAL or $EDITOR. - V Print version number of "less". - --------------------------------------------------------------------------- - - OOPPTTIIOONNSS - - Most options may be changed either on the command line, - or from within less by using the - or -- command. - Options may be given in one of two forms: either a single - character preceded by a -, or a name preceded by --. - - -? ........ --help - Display help (from command line). - -a ........ --search-skip-screen - Search skips current screen. - -A ........ --SEARCH-SKIP-SCREEN - Search starts just after target line. - -b [_N] .... --buffers=[_N] - Number of buffers. - -B ........ --auto-buffers - Don't automatically allocate buffers for pipes. - -c ........ --clear-screen - Repaint by clearing rather than scrolling. - -d ........ --dumb - Dumb terminal. - -D xx_c_o_l_o_r . --color=xx_c_o_l_o_r - Set screen colors. - -e -E .... --quit-at-eof --QUIT-AT-EOF - Quit at end of file. - -f ........ --force - Force open non-regular files. - -F ........ --quit-if-one-screen - Quit if entire file fits on first screen. - -g ........ --hilite-search - Highlight only last match for searches. - -G ........ --HILITE-SEARCH - Don't highlight any matches for searches. - -h [_N] .... --max-back-scroll=[_N] - Backward scroll limit. - -i ........ --ignore-case - Ignore case in searches that do not contain uppercase. - -I ........ --IGNORE-CASE - Ignore case in all searches. - -j [_N] .... --jump-target=[_N] - Screen position of target lines. - -J ........ --status-column - Display a status column at left edge of screen. - -k [_f_i_l_e] . --lesskey-file=[_f_i_l_e] - Use a lesskey file. - -K ........ --quit-on-intr - Exit less in response to ctrl-C. - -L ........ --no-lessopen - Ignore the LESSOPEN environment variable. - -m -M .... --long-prompt --LONG-PROMPT - Set prompt style. - -n -N .... --line-numbers --LINE-NUMBERS - Don't use line numbers. - -o [_f_i_l_e] . --log-file=[_f_i_l_e] - Copy to log file (standard input only). - -O [_f_i_l_e] . --LOG-FILE=[_f_i_l_e] - Copy to log file (unconditionally overwrite). - -p [_p_a_t_t_e_r_n] --pattern=[_p_a_t_t_e_r_n] - Start at pattern (from command line). - -P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t] - Define new prompt. - -q -Q .... --quiet --QUIET --silent --SILENT - Quiet the terminal bell. - -r -R .... --raw-control-chars --RAW-CONTROL-CHARS - Output "raw" control characters. - -s ........ --squeeze-blank-lines - Squeeze multiple blank lines. - -S ........ --chop-long-lines - Chop (truncate) long lines rather than wrapping. - -t [_t_a_g] .. --tag=[_t_a_g] - Find a tag. - -T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e] - Use an alternate tags file. - -u -U .... --underline-special --UNDERLINE-SPECIAL - Change handling of backspaces. - -V ........ --version - Display the version number of "less". - -w ........ --hilite-unread - Highlight first new line after forward-screen. - -W ........ --HILITE-UNREAD - Highlight first new line after any forward movement. - -x [_N[,...]] --tabs=[_N[,...]] - Set tab stops. - -X ........ --no-init - Don't use termcap init/deinit strings. - -y [_N] .... --max-forw-scroll=[_N] - Forward scroll limit. - -z [_N] .... --window=[_N] - Set size of window. - -" [_c[_c]] . --quotes=[_c[_c]] - Set shell quote characters. - -~ ........ --tilde - Don't display tildes after end of file. - -# [_N] .... --shift=[_N] - Set horizontal scroll amount (0 = one half screen width). - --file-size - Automatically determine the size of the input file. - --follow-name - The F command changes files if the input file is renamed. - --incsearch - Search file as each pattern character is typed in. - --line-num-width=N - Set the width of the -N line number field to N characters. - --mouse - Enable mouse input. - --no-keypad - Don't send termcap keypad init/deinit strings. - --no-histdups - Remove duplicates from command history. - --rscroll=C - Set the character used to mark truncated lines. - --save-marks - Retain marks across invocations of less. - --status-col-width=N - Set the width of the -J status column to N characters. - --use-backslash - Subsequent options use backslash as escape char. - --use-color - Enables colored text. - --wheel-lines=N - Each click of the mouse wheel moves N lines. - - - --------------------------------------------------------------------------- - - LLIINNEE EEDDIITTIINNGG - - These keys can be used to edit text being entered - on the "command line" at the bottom of the screen. - - RightArrow ..................... ESC-l ... Move cursor right one character. - LeftArrow ...................... ESC-h ... Move cursor left one character. - ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word. - ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word. - HOME ........................... ESC-0 ... Move cursor to start of line. - END ............................ ESC-$ ... Move cursor to end of line. - BACKSPACE ................................ Delete char to left of cursor. - DELETE ......................... ESC-x ... Delete char under cursor. - ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor. - ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor. - ctrl-U ......... ESC (MS-DOS only) ....... Delete entire line. - UpArrow ........................ ESC-k ... Retrieve previous command line. - DownArrow ...................... ESC-j ... Retrieve next command line. - TAB ...................................... Complete filename & cycle. - SHIFT-TAB ...................... ESC-TAB Complete filename & reverse cycle. - ctrl-L ................................... Complete filename, list all. diff --git a/api/ql -h localhost -U optima -d optima -c SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = 'Opportunity' ORDER BY ordinal_position; b/api/ql -h localhost -U optima -d optima -c SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = 'Opportunity' ORDER BY ordinal_position; deleted file mode 100644 index 5f8e1f6..0000000 --- a/api/ql -h localhost -U optima -d optima -c SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = 'Opportunity' ORDER BY ordinal_position; +++ /dev/null @@ -1,35 +0,0 @@ - column_name | data_type | is_nullable | column_default ----------------------+-----------------------------+-------------+-------------------- - id | integer | NO | - uid | text | NO | - name | text | NO | - notes | text | YES | - oppNarrative | text | YES | - typeId | integer | NO | - stageId | integer | YES | - statusId | integer | YES | - interest | USER-DEFINED | YES | - probability | double precision | NO | 0 - source | text | YES | - primarySalesRepId | text | YES | - secondarySalesRepId | text | YES | - companyId | integer | YES | - contactId | integer | YES | - siteId | integer | YES | - customerPO | text | YES | - locationId | integer | YES | - departmentId | integer | YES | - expectedCloseDate | timestamp without time zone | YES | - pipelineChangeDate | timestamp without time zone | YES | - dateBecameLead | timestamp without time zone | YES | - closedDate | timestamp without time zone | YES | - closedFlag | boolean | NO | false - closedById | text | YES | - productSequence | ARRAY | YES | ARRAY[]::integer[] - updatedBy | text | NO | - eneteredBy | text | NO | - createdAt | timestamp without time zone | NO | CURRENT_TIMESTAMP - updatedAt | timestamp without time zone | NO | - taxCodeId | integer | YES | -(31 rows) - diff --git a/api/src/api/sales/index.ts b/api/src/api/sales/index.ts index 5110699..374af81 100644 --- a/api/src/api/sales/index.ts +++ b/api/src/api/sales/index.ts @@ -34,6 +34,8 @@ import { default as fetchByUserId } from "./opportunities/fetchByUserId"; import { default as workflowDispatch } from "./opportunities/[id]/workflow/dispatch"; import { default as workflowStatus } from "./opportunities/[id]/workflow/status"; import { default as workflowHistory } from "./opportunities/[id]/workflow/history"; +import { default as addTime } from "./opportunities/[id]/time"; +import { default as fetchActivities } from "./opportunities/[id]/activities"; export { addProduct, @@ -72,4 +74,6 @@ export { workflowDispatch, workflowStatus, workflowHistory, + addTime, + fetchActivities, }; diff --git a/api/src/api/sales/opportunities/[id]/activities.ts b/api/src/api/sales/opportunities/[id]/activities.ts new file mode 100644 index 0000000..341d2b6 --- /dev/null +++ b/api/src/api/sales/opportunities/[id]/activities.ts @@ -0,0 +1,51 @@ +import { createRoute } from "../../../../modules/api-utils/createRoute"; +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 { activityCw } from "../../../../modules/cw-utils/activities/activities"; +import { OptimaType } from "../../../../workflows/wf.opportunity"; + +/* GET /v1/sales/opportunities/opportunity/:identifier/activities */ +export default createRoute( + "get", + ["/opportunities/opportunity/:identifier/activities"], + async (c) => { + const identifier = c.req.param("identifier"); + + const opportunity = await opportunities.fetchItem(identifier); + + const rawActivities = await activityCw.fetchByOpportunityDirect( + opportunity.cwOpportunityId, + ); + + // Return only open workflow activities (status != 2) + const openActivities = rawActivities + .filter((a: any) => a.status?.id !== 2) + .map((a: any) => { + const optimaTypeField = a.customFields?.find( + (f: any) => f.id === OptimaType.FIELD_ID || f.caption === "Optima_Type", + ); + const parentActivityField = a.customFields?.find( + (f: any) => f.id === 50 || f.caption === "Parent_Activity", + ); + return { + cwActivityId: a.id, + name: a.name, + optimaType: optimaTypeField?.value ?? null, + parentActivityId: parentActivityField?.value + ? parseInt(parentActivityField.value, 10) || null + : null, + status: a.status, + dateStart: a.dateStart ?? null, + dateEnd: a.dateEnd ?? null, + }; + }); + + const response = apiResponse.successful("Open activities fetched.", { + activities: openActivities, + }); + return c.json(response, response.status as ContentfulStatusCode); + }, + authMiddleware({ permissions: ["sales.opportunity.view"] }), +); diff --git a/api/src/api/sales/opportunities/[id]/time.ts b/api/src/api/sales/opportunities/[id]/time.ts new file mode 100644 index 0000000..db0f62f --- /dev/null +++ b/api/src/api/sales/opportunities/[id]/time.ts @@ -0,0 +1,112 @@ +import { z } from "zod"; +import { createRoute } from "../../../../modules/api-utils/createRoute"; +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 { cwMembers } from "../../../../managers/cwMembers"; +import { activityCw } from "../../../../modules/cw-utils/activities/activities"; +import { submitTimeEntry } from "../../../../services/cw.opportunityService"; +import { OptimaType } from "../../../../workflows/wf.opportunity"; +import GenericError from "../../../../Errors/GenericError"; + +const addTimeSchema = z.object({ + /** CW activity ID to log time against. */ + activityId: z.number().int().positive(), + /** ISO-8601 datetime when work started. */ + timeStarted: z.string().datetime(), + /** ISO-8601 datetime when work ended. */ + timeEnded: z.string().datetime(), + /** Optional notes for the time entry. */ + notes: z.string().optional(), +}); + +/* POST /v1/sales/opportunities/opportunity/:identifier/time */ +export default createRoute( + "post", + ["/opportunities/opportunity/:identifier/time"], + async (c) => { + const identifier = c.req.param("identifier"); + const body = await c.req.json(); + const data = addTimeSchema.parse(body); + + const user = c.get("user"); + + if (!user.cwIdentifier) { + throw new GenericError({ + status: 400, + name: "MissingCwIdentifier", + message: + "Your account is not linked to a ConnectWise member. A CW member association is required to log time.", + }); + } + + // Verify the opportunity exists and belongs to the correct identifier + const opportunity = await opportunities.fetchItem(identifier); + + const cwMember = await cwMembers.fetch(user.cwIdentifier); + + // Fetch the activity to verify it belongs to this opportunity and is open + const activity = await activityCw.fetch(data.activityId); + + if (activity.opportunity?.id !== opportunity.cwOpportunityId) { + throw new GenericError({ + status: 400, + name: "ActivityMismatch", + message: "The specified activity does not belong to this opportunity.", + }); + } + + if (activity.status?.id === 2) { + throw new GenericError({ + status: 400, + name: "ActivityClosed", + message: "Cannot log time against a closed activity.", + }); + } + + // Submit the time entry + const result = await submitTimeEntry({ + activityId: data.activityId, + cwMemberId: cwMember.cwMemberId, + timeStart: data.timeStarted, + timeEnd: data.timeEnded, + notes: data.notes ?? "", + }); + + if (!result.success) { + throw new GenericError({ + status: 502, + name: "TimeEntryFailed", + message: result.message, + }); + } + + // If the activity is a Schedule Entry (Optima_Type = "Schedule Entry"), + // close it now that time has been logged against it. + const optimaTypeField = activity.customFields?.find( + (f: any) => f.id === OptimaType.FIELD_ID || f.caption === "Optima_Type", + ); + if (optimaTypeField?.value === OptimaType.ScheduleEntry) { + try { + await activityCw.update(data.activityId, [ + { op: "replace", path: "status", value: { id: 2 } }, + ]); + } catch (closeErr) { + // Non-fatal — time entry was already submitted successfully + console.error( + `[AddTime] Failed to close Schedule Entry activity ${data.activityId}:`, + closeErr, + ); + } + } + + const response = apiResponse.successful("Time entry submitted successfully.", { + cwTimeEntryId: result.cwTimeEntryId, + activityId: data.activityId, + activityWasClosed: optimaTypeField?.value === OptimaType.ScheduleEntry, + }); + return c.json(response, response.status as ContentfulStatusCode); + }, + authMiddleware({ permissions: ["sales.opportunity.update"] }), +); diff --git a/api/src/api/sales/opportunities/[id]/update.ts b/api/src/api/sales/opportunities/[id]/update.ts index 7e4a57c..5578c21 100644 --- a/api/src/api/sales/opportunities/[id]/update.ts +++ b/api/src/api/sales/opportunities/[id]/update.ts @@ -5,6 +5,10 @@ import { ContentfulStatusCode } from "hono/utils/http-status"; import { authMiddleware } from "../../../middleware/authorization"; import GenericError from "../../../../Errors/GenericError"; import { z } from "zod"; +import { + OpportunityStatus, + StatusIdToKey, +} from "../../../../workflows/wf.opportunity"; const updateSchema = z .object({ @@ -46,6 +50,21 @@ export default createRoute( const item = await opportunities.fetchRecord(identifier); + // Read-only guard: only New and Active statuses allow opportunity data mutations. + const editableStatuses = new Set([ + OpportunityStatus.New, + OpportunityStatus.Active, + ]); + const currentStatusId = item.statusCwId ?? null; + if (currentStatusId !== null && !editableStatuses.has(currentStatusId)) { + const statusKey = StatusIdToKey[currentStatusId] ?? `ID ${currentStatusId}`; + throw new GenericError({ + status: 422, + name: "OpportunityReadOnly", + message: `Opportunity data cannot be edited in "${statusKey}" status. Only "New" and "Active" opportunities are editable.`, + }); + } + try { const updated = await item.updateOpportunity(data); diff --git a/api/src/api/sales/opportunities/[id]/workflow/dispatch.ts b/api/src/api/sales/opportunities/[id]/workflow/dispatch.ts index 72eec34..5f26a15 100644 --- a/api/src/api/sales/opportunities/[id]/workflow/dispatch.ts +++ b/api/src/api/sales/opportunities/[id]/workflow/dispatch.ts @@ -91,6 +91,19 @@ const dispatchSchema = z.discriminatedUnion("action", [ action: z.literal("reopen"), payload: noteRequiredPayload, }), + z.object({ + action: z.literal("sendBackForRevision"), + payload: noteRequiredPayload, + }), + z.object({ + action: z.literal("createScheduleEntry"), + payload: basePayload.extend({ + activityTypeValue: z.enum(["Follow-Up", "Appointment", "Admin"]), + dueDate: z.string().optional(), + startTime: z.string().optional(), + endTime: z.string().optional(), + }), + }), ]); // ── Route ───────────────────────────────────────────────────────────────── diff --git a/api/src/api/sales/opportunities/[id]/workflow/history.ts b/api/src/api/sales/opportunities/[id]/workflow/history.ts index 8153b20..c6cb344 100644 --- a/api/src/api/sales/opportunities/[id]/workflow/history.ts +++ b/api/src/api/sales/opportunities/[id]/workflow/history.ts @@ -22,6 +22,7 @@ const OPTIMA_TYPE_VALUES = new Set([ OptimaType.Revision, OptimaType.Finalized, OptimaType.Converted, + OptimaType.ScheduleEntry, ]); /** QuoteID custom field ID (matches wf.opportunity.ts QUOTE_ID_FIELD_ID). */ diff --git a/api/src/api/sales/opportunities/[id]/workflow/status.ts b/api/src/api/sales/opportunities/[id]/workflow/status.ts index eb4a470..0d0055d 100644 --- a/api/src/api/sales/opportunities/[id]/workflow/status.ts +++ b/api/src/api/sales/opportunities/[id]/workflow/status.ts @@ -248,6 +248,15 @@ const ACTION_MAP: Record = { requiresPermission: null, payloadHints: { needsRevision: "true" }, }, + { + action: "sendBackForRevision", + label: "Send Back for Revision", + targetStatuses: [ + { key: "PendingRevision", id: OpportunityStatus.PendingRevision }, + ], + requiresNote: true, + requiresPermission: null, + }, ], [OpportunityStatus.Active]: [ diff --git a/api/src/workflows/wf.opportunity.ts b/api/src/workflows/wf.opportunity.ts index 0a30c63..39fd681 100644 --- a/api/src/workflows/wf.opportunity.ts +++ b/api/src/workflows/wf.opportunity.ts @@ -123,6 +123,7 @@ export const OptimaType = { Revision: "Revision", Finalized: "Finalized", Converted: "Converted", + ScheduleEntry: "Schedule Entry", } as const; /** CW custom field ID for the QuoteID field on activities. */ @@ -131,6 +132,9 @@ const QUOTE_ID_FIELD_ID = 48; /** CW custom field ID for the Close Date field on activities. */ const CLOSE_DATE_FIELD_ID = 49; +/** CW custom field ID for the Parent Activity field on activities. */ +export const PARENT_ACTIVITY_FIELD_ID = 50; + /** * Optima_Type values whose activities should remain Open until the * next workflow transition closes them automatically. @@ -139,6 +143,7 @@ const STAYS_OPEN_TYPES = new Set([ OptimaType.OpportunitySetup, OptimaType.OpportunityReview, OptimaType.Revision, + OptimaType.ScheduleEntry, ]); export type OptimaTypeValue = @@ -151,7 +156,8 @@ export type OptimaTypeValue = | typeof OptimaType.QuoteGenerated | typeof OptimaType.Revision | typeof OptimaType.Finalized - | typeof OptimaType.Converted; + | typeof OptimaType.Converted + | typeof OptimaType.ScheduleEntry; /** Permission nodes required by gated transitions. */ export const WorkflowPermissions = { @@ -210,6 +216,7 @@ const ALLOWED_TRANSITIONS: Record> = { OpportunityStatus.PendingWon, OpportunityStatus.PendingLost, OpportunityStatus.Active, + OpportunityStatus.PendingRevision, // needs revision OpportunityStatus.InternalReview, // cold automation only ]), @@ -219,6 +226,7 @@ const ALLOWED_TRANSITIONS: Record> = { OpportunityStatus.PendingLost, OpportunityStatus.Active, OpportunityStatus.InternalReview, // cold automation only + OpportunityStatus.PendingRevision, // send back for revision ]), [OpportunityStatus.Active]: new Set([ @@ -317,7 +325,7 @@ export interface SendQuotePayload extends BaseActionPayload { /** * Quote needs revision. - * Creates a revision activity and transitions to Active. + * Creates a revision activity and transitions to PendingRevision. */ needsRevision?: boolean; } @@ -342,6 +350,25 @@ export interface ResurrectPayload extends BaseActionPayload { /** Begin revision from PendingRevision → Active. */ export interface BeginRevisionPayload extends BaseActionPayload {} +/** Send back for revision from ConfirmedQuote → PendingRevision. */ +export interface SendBackForRevisionPayload extends BaseActionPayload { + note: string; // required +} + +/** Create a Schedule Entry activity (no status change). */ +export interface CreateScheduleEntryPayload extends BaseActionPayload { + /** Activity type value: Follow-Up, Appointment, or Admin. */ + activityTypeValue: "Follow-Up" | "Appointment" | "Admin"; + /** ISO-8601 due date. */ + dueDate?: string; + /** ISO-8601 start time. */ + startTime?: string; + /** ISO-8601 end time. */ + endTime?: string; + /** Optional notes for the schedule entry. */ + note?: string; +} + /** Re-send from Active → QuoteSent. */ export interface ResendQuotePayload extends SendQuotePayload {} @@ -371,7 +398,9 @@ export type WorkflowAction = | { action: "beginRevision"; payload: BeginRevisionPayload } | { action: "resendQuote"; payload: ResendQuotePayload } | { action: "cancel"; payload: CancelPayload } - | { action: "reopen"; payload: ReopenPayload }; + | { action: "reopen"; payload: ReopenPayload } + | { action: "sendBackForRevision"; payload: SendBackForRevisionPayload } + | { action: "createScheduleEntry"; payload: CreateScheduleEntryPayload }; // ----------------------------------------------------------------------- // Result @@ -1083,9 +1112,9 @@ export async function transitionToQuoteSent( return ok(currentStatus, targetStatus, activities); } - // ── needsRevision flag → Active ──────────────────────────────────── + // ── needsRevision flag → PendingRevision ──────────────────────────── if (payload.needsRevision) { - const targetStatus = OpportunityStatus.Active; + const targetStatus = OpportunityStatus.PendingRevision; const wasAlsoConfirmed = !!payload.quoteConfirmed; @@ -1518,6 +1547,117 @@ export async function beginRevision( return ok(currentStatus, targetStatus, activities); } +/** + * ConfirmedQuote → PendingRevision + * + * Sends the opportunity back for revision from ConfirmedQuote. + * Requires a mandatory note explaining why. + */ +export async function sendBackForRevision( + opportunity: OpportunityController, + user: WorkflowUser, + payload: SendBackForRevisionPayload, +): Promise { + const currentStatus = opportunity.statusCwId; + if (currentStatus == null) return fail("Opportunity has no current status."); + + const noteErr = assertNotePresent(payload.note); + if (noteErr) return fail(noteErr, currentStatus); + + const targetStatus = OpportunityStatus.PendingRevision; + + const transErr = assertTransitionAllowed(currentStatus, targetStatus); + if (transErr) return fail(transErr, currentStatus); + + const activity = await createWorkflowActivity({ + name: `[Workflow] Sent back for revision — ${opportunity.name}`, + opportunityCwId: opportunity.cwOpportunityId, + companyCwId: opportunity.companyCwId, + assignToCwMemberId: user.cwMemberId, + notes: payload.note, + optimaType: OptimaType.Revision, + }); + + await syncOpportunityStatus({ + opportunityId: opportunity.cwOpportunityId, + statusCwId: targetStatus, + }); + + await handleTimeEntry( + activity.cwActivityId, + user.cwMemberId, + payload, + payload.note, + ); + + return ok(currentStatus, targetStatus, [activity]); +} + +/** + * Create a Schedule Entry activity without changing the opportunity status. + * + * Schedule Entry activities stay open until time is logged against them. + */ +export async function createScheduleEntry( + opportunity: OpportunityController, + user: WorkflowUser, + payload: CreateScheduleEntryPayload, +): Promise { + const currentStatus = opportunity.statusCwId; + if (currentStatus == null) return fail("Opportunity has no current status."); + + // CW activities require ISO-8601 without milliseconds, e.g. "2026-04-19T20:15:00Z" + const toCwDateTime = (iso: string): string => iso.replace(/\.\d+Z$/, "Z"); + + const dateStart = payload.startTime + ? toCwDateTime(payload.startTime) + : payload.dueDate + ? toCwDateTime(payload.dueDate) + : undefined; + const dateEnd = payload.endTime ? toCwDateTime(payload.endTime) : undefined; + + const activity = await ActivityController.create({ + name: `[Schedule Entry] ${payload.activityTypeValue} — ${opportunity.name}`, + type: { id: 3 }, // HistoricEntry + opportunity: { id: opportunity.cwOpportunityId }, + ...(opportunity.companyCwId ? { company: { id: opportunity.companyCwId } } : {}), + assignTo: { id: user.cwMemberId }, + notes: payload.note ?? "", + ...(dateStart ? { dateStart } : {}), + ...(dateEnd ? { dateEnd } : {}), + }); + + // Set Optima_Type to Schedule Entry (stays open) + await activity.update([ + { + op: "replace", + path: "customFields", + value: [ + { + id: OptimaType.FIELD_ID, + caption: "Optima_Type", + type: "Text", + entryMethod: "List", + numberOfDecimals: 0, + value: OptimaType.ScheduleEntry, + }, + ], + }, + ]); + + // Return a no-transition result (status unchanged) + return { + success: true, + previousStatusId: currentStatus, + newStatusId: currentStatus, + previousStatus: StatusIdToKey[currentStatus] ?? null, + newStatus: StatusIdToKey[currentStatus] ?? null, + activitiesCreated: [activity], + coldCheck: null, + error: null, + }; +} + /** * Any cancelable status → Canceled * @@ -1732,6 +1872,9 @@ async function closeOpenWorkflowActivities( // Only close activities whose type is in the stays-open set if (!STAYS_OPEN_TYPES.has(optimaField.value as OptimaTypeValue)) continue; + // Never auto-close Schedule Entry activities — they close only when time is logged + if (optimaField.value === OptimaType.ScheduleEntry) continue; + const closeDate = new Date().toISOString(); const existingFields = (raw.customFields ?? []).map((f: any) => f.id === CLOSE_DATE_FIELD_ID ? { ...f, value: closeDate } : f, @@ -1797,7 +1940,10 @@ export async function processOpportunityAction( } // ── Close any open workflow activities from previous stage ────────── - await closeOpenWorkflowActivities(opportunity.cwOpportunityId); + // Skip for createScheduleEntry — we intentionally preserve open activities. + if (action !== "createScheduleEntry") { + await closeOpenWorkflowActivities(opportunity.cwOpportunityId); + } // ── Route to transition function ──────────────────────────────────── let result: WorkflowResult; @@ -1856,6 +2002,16 @@ export async function processOpportunityAction( result = await reopenCancelledOpportunity(opportunity, user, payload); break; + case "sendBackForRevision": + result = await sendBackForRevision(opportunity, user, payload); + break; + + case "createScheduleEntry": + // Schedule Entry does not close open activities — skip that step. + // We call it directly rather than falling through closeOpenWorkflowActivities. + result = await createScheduleEntry(opportunity, user, payload); + break; + default: { const _exhaustive: never = action; return fail(`Unknown workflow action: "${_exhaustive}"`); diff --git a/api/tmp-check-opp-schema.ts b/api/tmp-check-opp-schema.ts deleted file mode 100644 index 0f956e0..0000000 --- a/api/tmp-check-opp-schema.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Client } from "pg"; - -const url = process.env.DATABASE_URL ?? "postgresql://optima:123web123@localhost:5432/optima"; -const c = new Client(url); -await c.connect(); - -const r = await c.query( - "SELECT column_name, is_nullable, column_default FROM information_schema.columns WHERE table_name = 'Opportunity' ORDER BY ordinal_position" -); - -console.log("Opportunity columns:"); -for (const row of r.rows) { - const nullable = row.is_nullable === "YES" ? "nullable" : "NOT NULL"; - console.log(` ${row.column_name}: ${nullable}${row.column_default ? ` (default: ${row.column_default})` : ""}`); -} - -await c.end(); diff --git a/ui/src/components/CreateOpportunityModal.svelte b/ui/src/components/CreateOpportunityModal.svelte index 9733462..c3c8703 100644 --- a/ui/src/components/CreateOpportunityModal.svelte +++ b/ui/src/components/CreateOpportunityModal.svelte @@ -83,10 +83,50 @@ let dropdownStyle = ""; let primaryRepSelect: HTMLSelectElement; + // ── Schedule Entry state ── + type ScheduleEntryType = "Follow-Up" | "Appointment" | "Admin"; + let scheduleEntryType: ScheduleEntryType = "Follow-Up"; + let scheduleEntryStartDate = ""; + let scheduleEntryStartHour = ""; + let scheduleEntryEndDate = ""; + let scheduleEntryEndHour = ""; + let scheduleEntryNote = ""; + let skipScheduleEntry = false; + let scheduleEntryTimeError = ""; + + function toCoLocalDate(d: Date): string { + const y = d.getFullYear(); + const mo = String(d.getMonth() + 1).padStart(2, "0"); + const da = String(d.getDate()).padStart(2, "0"); + return `${y}-${mo}-${da}`; + } + + function toCoLocalTime(d: Date): string { + const h = String(d.getHours()).padStart(2, "0"); + const mi = String(d.getMinutes()).padStart(2, "0"); + return `${h}:${mi}`; + } + + function initScheduleEntryTimes() { + if (scheduleEntryStartDate && scheduleEntryStartHour && scheduleEntryEndDate && scheduleEntryEndHour) return; // already set + const now = new Date(); + const mins = now.getMinutes(); + const roundedUp = Math.ceil(mins / 15) * 15; + const ended = new Date(now); + ended.setMinutes(roundedUp, 0, 0); + if (ended <= now) ended.setHours(ended.getHours() + 1); + const started = new Date(ended.getTime() - 60 * 60 * 1000); + scheduleEntryStartDate = toCoLocalDate(started); + scheduleEntryStartHour = toCoLocalTime(started); + scheduleEntryEndDate = toCoLocalDate(ended); + scheduleEntryEndHour = toCoLocalTime(ended); + } + // ── Opportunity types state ── let loadedTypes: OpportunityType[] = []; let isLoadingTypes = false; $: resolvedTypes = opportunityTypes.length > 0 ? opportunityTypes : loadedTypes; + $: selectedTypeName = resolvedTypes.find((t) => String(t.id) === typeId)?.name ?? null; async function loadOpportunityTypes() { if (opportunityTypes.length > 0) return; @@ -110,14 +150,16 @@ { label: "Details", icon: "document" }, { label: "Assignment", icon: "people" }, { label: "Contact & Site", icon: "contact" }, + { label: "Schedule Entry", icon: "calendar" }, { label: "Review", icon: "check" }, ]; // ── Validation ── - $: isStep0Valid = name.trim().length > 0 && expectedCloseDate.length > 0; + $: isStep0Valid = name.trim().length > 0 && expectedCloseDate.length > 0 && typeId.length > 0; $: isStep1Valid = primarySalesRepId.length > 0 && companyId.length > 0; $: isStep2Valid = true; // Contact & Site step is optional - $: isValid = isStep0Valid && isStep1Valid && isStep2Valid; + $: isStep3Valid = true; // Schedule Entry step is optional (can be skipped) + $: isValid = isStep0Valid && isStep1Valid && isStep2Valid && isStep3Valid; $: selectedPrimaryRep = $cwMembers.find( (m) => String(m.id) === primarySalesRepId, @@ -321,10 +363,11 @@ function nextStep() { if (currentStep === 0 && !isStep0Valid) return; if (currentStep === 1 && !isStep1Valid) return; - if (currentStep < 3) { + if (currentStep < 4) { currentStep++; if (currentStep === 1) loadMembers(); if (currentStep === 2) loadCompanyDetails(); + if (currentStep === 3) initScheduleEntryTimes(); } } @@ -349,6 +392,11 @@ } if (step === 3 && isStep0Valid && isStep1Valid && isStep2Valid) { currentStep = 3; + initScheduleEntryTimes(); + return; + } + if (step === 4 && isStep0Valid && isStep1Valid && isStep2Valid && isStep3Valid) { + currentStep = 4; return; } } @@ -391,6 +439,33 @@ console.warn("No opportunity ID in response:", JSON.stringify(result)); } + // ── Create schedule entry if the user filled one in ──────────────────── + if (!skipScheduleEntry && newId) { + try { + await clientFetch(`/sales/opportunity/${newId}/workflow`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "createScheduleEntry", + payload: { + activityTypeValue: scheduleEntryType, + ...(scheduleEntryEndDate ? { dueDate: scheduleEntryEndDate } : {}), + ...(scheduleEntryStartDate && scheduleEntryStartHour + ? { startTime: new Date(`${scheduleEntryStartDate}T${scheduleEntryStartHour}`).toISOString() } + : {}), + ...(scheduleEntryEndDate && scheduleEntryEndHour + ? { endTime: new Date(`${scheduleEntryEndDate}T${scheduleEntryEndHour}`).toISOString() } + : {}), + ...(scheduleEntryNote.trim() ? { note: scheduleEntryNote.trim() } : {}), + }, + }), + }); + } catch { + // Non-fatal: the opportunity was created; log and continue. + console.warn("[CreateOpportunityModal] Schedule entry creation failed after opportunity was created."); + } + } + reset(); if (shouldNavigate) { @@ -433,6 +508,14 @@ submitError = ""; currentStep = 0; navigateOnCreate = true; + scheduleEntryType = "Follow-Up"; + scheduleEntryStartDate = ""; + scheduleEntryStartHour = ""; + scheduleEntryEndDate = ""; + scheduleEntryEndHour = ""; + scheduleEntryNote = ""; + skipScheduleEntry = false; + scheduleEntryTimeError = ""; } function handleClose() { @@ -517,11 +600,13 @@ class:completed={i < currentStep} class:disabled={(i > 0 && !isStep0Valid) || (i > 1 && !isStep1Valid) || - (i > 2 && !isStep2Valid)} + (i > 2 && !isStep2Valid) || + (i > 3 && !isStep3Valid)} on:click={() => goToStep(i)} disabled={(i > 0 && !isStep0Valid) || (i > 1 && !isStep1Valid) || - (i > 2 && !isStep2Valid)} + (i > 2 && !isStep2Valid) || + (i > 3 && !isStep3Valid)} > {#if i < currentStep} @@ -632,6 +717,21 @@ /> +
+ + +
+
- -
- - -
@@ -1227,8 +1312,136 @@ - + {:else if currentStep === 3} +
+
+

+ + + + + + + Initial Schedule Entry +

+

+ Optionally create a schedule entry for this opportunity right away. You can skip this and add entries later. +

+ + + + {#if !skipScheduleEntry} +
+ +
+ +
+ {#each (["Follow-Up", "Appointment", "Admin"] as const) as t} + + {/each} +
+
+ + +
+ + { scheduleEntryTimeError = ""; }} + /> +
+ + +
+ + { scheduleEntryTimeError = ""; }} + /> +
+ + +
+ + { scheduleEntryTimeError = ""; }} + /> +
+ + +
+ + { scheduleEntryTimeError = ""; }} + /> +
+ + {#if scheduleEntryTimeError} +
{scheduleEntryTimeError}
+ {/if} + + +
+ + +
+
+ {/if} +
+
+ + + {:else if currentStep === 4}
@@ -1280,6 +1493,12 @@ })}
+ {#if selectedTypeName} +
+
Type
+
{selectedTypeName}
+
+ {/if} {#if source}
Source
@@ -1390,6 +1609,39 @@
{/if} + + {#if !skipScheduleEntry} +
+

+ + + + + Schedule Entry +

+
+
+
Type
+
{scheduleEntryType}
+
+ {#if scheduleEntryStartDate && scheduleEntryStartHour} +
+
Start
+
{new Date(`${scheduleEntryStartDate}T${scheduleEntryStartHour}`).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}
+
+ {/if} + {#if scheduleEntryEndDate && scheduleEntryEndHour} +
+
End
+
{new Date(`${scheduleEntryEndDate}T${scheduleEntryEndHour}`).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}
+
+ {/if} +
+
+ {:else} +

No schedule entry — will be added later.

+ {/if} +
+ +
+ {#if isLoadingActivities} +
+ + Loading activities… +
+ {:else if activities.length === 0} +
+ + + +

No open activities

+

There are no open activities on this opportunity to log time against. You can create a schedule entry instead.

+
+ {:else} + + {#if error} +
+ + + + {error} +
+ {/if} + + + {#if activities.length > 1} +
+ + + {#if activityIdError} + {activityIdError} + {/if} +
+ {:else} +
+ +
+ + + + {activities[0].name}{activities[0].optimaType ? ` · ${activities[0].optimaType}` : ""} +
+
+ {/if} + + +
+
+ + (timeError = "")} + /> +
+
+ + (timeError = "")} + /> +
+
+ + (timeError = "")} + /> +
+
+ + (timeError = "")} + /> +
+
+ {#if timeError} + {timeError} + {/if} + + +
+ + +
+ + {/if} +
+ + {#if isLoadingActivities} + + {:else if activities.length === 0} + + {:else} + + {/if} +
+ +{/if} + + diff --git a/ui/src/routes/sales/opportunity/[id]/components/CreateScheduleEntryModal.svelte b/ui/src/routes/sales/opportunity/[id]/components/CreateScheduleEntryModal.svelte new file mode 100644 index 0000000..42678e9 --- /dev/null +++ b/ui/src/routes/sales/opportunity/[id]/components/CreateScheduleEntryModal.svelte @@ -0,0 +1,412 @@ + + +{#if isOpen} + +{/if} + + diff --git a/ui/src/routes/sales/opportunity/[id]/components/WorkflowPanel.svelte b/ui/src/routes/sales/opportunity/[id]/components/WorkflowPanel.svelte index b70b087..21f773d 100644 --- a/ui/src/routes/sales/opportunity/[id]/components/WorkflowPanel.svelte +++ b/ui/src/routes/sales/opportunity/[id]/components/WorkflowPanel.svelte @@ -15,6 +15,7 @@ TERMINAL_STATUSES, REOPENABLE_STATUSES, QUOTE_CONFIRMED_STATUSES, + ADD_TIME_EXCLUDED_STATUSES, } from "$lib/optima-api/modules/sales"; import type { PermissionMap } from "$lib/permissions"; import type { @@ -25,6 +26,9 @@ import SendQuoteModal from "./SendQuoteModal.svelte"; import ReviewDecisionModal from "./ReviewDecisionModal.svelte"; import FinalizeModal from "./FinalizeModal.svelte"; + import AddTimeModal from "./AddTimeModal.svelte"; + import CreateScheduleEntryModal from "./CreateScheduleEntryModal.svelte"; + import { formatDateTime } from "../types"; const dispatch = createEventDispatcher<{ workflowChanged: { result: WorkflowResult; freshWorkflowStatus: WorkflowStatusResponse | null }; @@ -44,6 +48,16 @@ let isSubmitting = false; let preselectedOutcome: "won" | "lost" | null = null; + // Add Time modal state + let showAddTimeModal = false; + let addTimeActivities: Array<{ cwActivityId: number; name: string; optimaType: string | null }> = []; + let isLoadingActivities = false; + + // Create Schedule Entry modal state + let showScheduleEntryModal = false; + let scheduleEntryError: string | null = null; + let isScheduleEntrySubmitting = false; + // Derived state $: statusKey = workflowStatus ? (STATUS_ID_TO_KEY[workflowStatus.currentStatusId] ?? null) @@ -76,6 +90,21 @@ $: canReopen = permissions["sales.opportunity.reopen"] !== false; $: hasQuotes = quotes.length > 0; + /** "Add Time" is available on all statuses except terminal/pending-outcome/canceled */ + $: canShowAddTime = statusKey + ? !ADD_TIME_EXCLUDED_STATUSES.has(statusKey) && isOptimaStage + : false; + + /** Open (unclosed) schedule entries for this opportunity */ + $: openScheduleEntries = activities + .filter((a) => { + const optimaType = a.customFields?.find( + (f) => f.caption === "Optima_Type" || f.id === 45, + )?.value; + return optimaType === "Schedule Entry" && a.status?.id !== 2; + }) + .sort((a, b) => (a.dateStart ?? "").localeCompare(b.dateStart ?? "")); + /** For resendQuote: check that a quote was generated after the last revision opening */ $: hasQuoteSinceRevision = (() => { if (!hasQuotes) return false; @@ -209,6 +238,8 @@ cancel: "Cancel", reopen: "Reopen", acceptNew: "Accept", + sendBackForRevision: "Send Back for Revision", + createScheduleEntry: "Create Schedule Entry", }; return overrides[action.action] ?? action.label; } @@ -299,6 +330,72 @@ activeModal = e.detail.action; } + /** Open the Add Time modal */ + function openAddTimeModal() { + showAddTimeModal = true; + } + + /** Handle successful time submission from AddTimeModal */ + function handleAddTimeSuccess() { + showAddTimeModal = false; + dispatch("workflowChanged", { + result: { + success: true, + previousStatusId: workflowStatus?.currentStatusId ?? null, + newStatusId: workflowStatus?.currentStatusId ?? null, + previousStatus: null, + newStatus: null, + activitiesCreated: [], + coldCheck: null, + error: null, + }, + freshWorkflowStatus: null, + }); + } + + /** Submit a Create Schedule Entry action */ + async function submitScheduleEntry(payload: WorkflowActionPayload) { + isScheduleEntrySubmitting = true; + scheduleEntryError = null; + try { + const json = await clientFetch<{ + successful: boolean; + message?: string; + data?: { error?: string }; + }>(`/sales/opportunity/${opportunityId}/workflow`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "createScheduleEntry", payload }), + }); + + if (json.successful === false) { + scheduleEntryError = json.message ?? json.data?.error ?? "Failed to create schedule entry."; + return; + } + + showScheduleEntryModal = false; + const result = json.data as WorkflowResult; + if (result?.activitiesCreated?.length) { + activities = [...result.activitiesCreated, ...activities]; + } + + // Fetch fresh workflow status + let freshWorkflowStatus: WorkflowStatusResponse | null = null; + try { + const statusJson = await clientFetch<{ data: WorkflowStatusResponse }>( + `/sales/opportunity/${opportunityId}/workflow`, + ); + freshWorkflowStatus = statusJson.data ?? null; + } catch { /* ignore */ } + + dispatch("workflowChanged", { result: result ?? { success: true, previousStatusId: null, newStatusId: null, previousStatus: null, newStatus: null, activitiesCreated: [], coldCheck: null, error: null }, freshWorkflowStatus }); + } catch (err) { + scheduleEntryError = err instanceof Error ? err.message : "Failed to create schedule entry."; + } finally { + isScheduleEntrySubmitting = false; + } + } + /** Determine if action needs a special modal */ function isSpecialModal(action: WorkflowAction): boolean { return ["sendQuote", "resendQuote", "reviewDecision", "finalize"].includes( @@ -494,6 +591,68 @@ {/if} {/if} + + + {#if canShowAddTime || true} + + + + + + + {#if canShowAddTime} + + + + {/if} + {/if} + + {/if} + + + {#if openScheduleEntries.length > 0} +
+ {#each openScheduleEntries as entry} +
+ + + + + + + {entry.name ?? "Schedule Entry"} + {#if entry.dateStart} + + {formatDateTime(entry.dateStart)}{#if entry.dateEnd} – {formatDateTime(entry.dateEnd)}{/if} + + {/if} +
+ {/each}
{/if} @@ -586,3 +745,21 @@ on:close={closeModal} /> {/if} + + + { showAddTimeModal = false; }} + on:createScheduleEntry={() => { showAddTimeModal = false; showScheduleEntryModal = true; }} +/> + + + submitScheduleEntry(e.detail)} + on:close={() => { showScheduleEntryModal = false; scheduleEntryError = null; }} +/> diff --git a/ui/src/routes/sales/opportunity/[id]/time/+server.ts b/ui/src/routes/sales/opportunity/[id]/time/+server.ts new file mode 100644 index 0000000..7b14729 --- /dev/null +++ b/ui/src/routes/sales/opportunity/[id]/time/+server.ts @@ -0,0 +1,32 @@ +import { optima } from "$lib"; +import { json, error } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; + +/** POST /sales/opportunity/[id]/time — log time against an activity */ +export const POST: RequestHandler = async ({ params, request, locals }) => { + const accessToken = locals.session?.accessToken; + if (!accessToken) throw error(401, "Unauthorized"); + + const body = await request.json(); + + try { + const result = await optima.sales.addTimeToActivity( + accessToken, + params.id, + body, + ); + return json(result); + } catch (err: unknown) { + console.error("[Time] Failed to log time:", err); + const status = + err && typeof err === "object" && "status" in err + ? (err as { status: number }).status + : 500; + const message = + err && typeof err === "object" && "response" in err + ? ((err as { response?: { data?: { message?: string } } }).response + ?.data?.message ?? "Failed to log time") + : "Failed to log time"; + throw error(status, message); + } +}; diff --git a/ui/src/styles/sales/opportunitydetail.css b/ui/src/styles/sales/opportunitydetail.css index 59071e6..17caf0d 100644 --- a/ui/src/styles/sales/opportunitydetail.css +++ b/ui/src/styles/sales/opportunitydetail.css @@ -4668,6 +4668,52 @@ flex-shrink: 0; } +/* ── Pending Schedule Entries ── */ +.wf-schedule-entries { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 8px; + padding: 7px 10px; + border-radius: 8px; + background: rgba(59, 130, 246, 0.05); + border: 1px solid rgba(59, 130, 246, 0.15); +} + +.wf-se-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 11.5px; + color: var(--text-primary); + min-width: 0; +} + +.wf-se-icon { + flex-shrink: 0; + color: #3b82f6; +} + +.wf-se-name { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +.wf-se-time { + color: var(--text-muted); + white-space: nowrap; + flex-shrink: 0; + font-size: 11px; +} + +.wf-panel-inline .wf-schedule-entries { + display: none; +} + /* ── Error Banner ── */ .wf-error-banner { display: flex; @@ -4801,6 +4847,35 @@ box-shadow: 0 2px 4px rgba(22, 163, 74, 0.1); } +/* Icon-only square button (calendar / clock) */ +.wf-action-btn.wf-btn-icon { + width: 32px; + height: 32px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border-color, #444); + border-radius: 6px; + background: transparent; + color: var(--text-muted, #888); + flex-shrink: 0; + cursor: pointer; + transition: border-color 0.15s, color 0.15s, background 0.15s; +} +.wf-action-btn.wf-btn-icon:hover:not(:disabled) { + border-color: var(--accent-color, #4f8ef7); + color: var(--accent-color, #4f8ef7); + background: var(--accent-bg, rgba(79, 142, 247, 0.08)); +} + +/* Small spinner for icon buttons */ +.wf-spinner.wf-spinner-sm { + width: 14px; + height: 14px; + border-width: 2px; +} + /* Action group separator */ .wf-action-sep { width: 1px; @@ -6022,19 +6097,34 @@ /* Subtle highlight for open/active activities */ } +/* Nested child activities (parent-child hierarchy via Parent_Activity field) */ +.at-entry.at-entry-child { + margin-left: 28px; + position: relative; +} +.at-entry.at-entry-child::before { + content: ""; + position: absolute; + left: -16px; + top: 12px; + width: 12px; + height: 2px; + background: var(--card-border); +} + /* ── Connector (dots + lines) ── */ .at-connector { display: flex; flex-direction: column; align-items: center; - width: 24px; + width: 20px; flex-shrink: 0; padding-top: 2px; } .at-dot { - width: 24px; - height: 24px; + width: 20px; + height: 20px; border-radius: 50%; background: rgba(59, 130, 246, 0.12); color: #3b82f6; @@ -6047,7 +6137,6 @@ .at-dot.at-dot-open { background: rgba(34, 197, 94, 0.15); color: #16a34a; - box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2); } .at-dot.at-dot-system { @@ -6266,3 +6355,11 @@ .at-closed-at { opacity: 0.7; } + +.at-schedule-time { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 11px; + color: var(--accent-color, #4f8ef7); +}