@plusscommunities/pluss-maintenance-web-forms 1.1.35-beta.2 → 1.2.4-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1271 +1,1531 @@
1
- import React, { Component } from 'react';
2
- import { withRouter } from 'react-router';
3
- import { Link } from 'react-router-dom';
4
- import moment from 'moment';
5
- import _ from 'lodash';
6
- import FontAwesome from 'react-fontawesome';
7
- import Textarea from 'react-textarea-autosize';
8
- import { connect } from 'react-redux';
9
- import { jobsLoaded, jobStatusesUpdate } from '../actions';
10
- import Config, { PlussCore } from '../feature.config';
11
- import { maintenanceActions, reactionActions } from '../apis';
12
- import { STATUS_NOT_ACTIONED, jobPriorityOptions, getJobPriority } from '../helper';
13
- import { values } from '../values.config';
1
+ import React, { Component } from "react";
2
+ import { withRouter } from "react-router";
3
+ import { Link } from "react-router-dom";
4
+ import moment from "moment";
5
+ import _ from "lodash";
6
+ import FontAwesome from "react-fontawesome";
7
+ import Textarea from "react-textarea-autosize";
8
+ import { connect } from "react-redux";
9
+ import { jobsLoaded, jobStatusesUpdate } from "../actions";
10
+ import Config, { PlussCore } from "../feature.config";
11
+ import { maintenanceActions, reactionActions } from "../apis";
12
+ import {
13
+ STATUS_NOT_ACTIONED,
14
+ jobPriorityOptions,
15
+ getJobPriority,
16
+ } from "../helper";
17
+ import { values } from "../values.config";
14
18
 
15
19
  const { Apis, Helper, Session, Colours, Components } = PlussCore;
16
20
  const IMAGE_SIZE_NOTE = 72;
17
21
 
18
22
  class Job extends Component {
19
- constructor(props) {
20
- super(props);
21
- this.state = {
22
- jobId: Helper.safeReadParams(props, 'jobId') ? props.match.params.jobId : null,
23
- job: null,
24
- showingSelector: false,
25
- updating: false,
26
- comments: [],
27
- commentInput: '',
28
- loadingComments: false,
29
- priorityChangerOpen: false,
30
- statusChangerOpen: false,
31
- addNoteOpen: false,
32
- noteAttachments: [],
33
- noteInput: '',
34
- noteImages: [],
35
- assignees: [],
36
- externalSync: null,
37
- loadingExternalSync: false,
38
- retryingSync: false,
39
- retrySyncError: null,
40
- retrySyncInitiated: false,
41
- };
42
- }
43
-
44
- UNSAFE_componentWillReceiveProps(nextProps) {
45
- Session.checkLoggedIn(this, this.props.auth);
46
- }
47
-
48
- componentDidMount() {
49
- this.props.jobStatusesUpdate(this.props.auth.site);
50
- if (this.state.jobId) {
51
- this.getJob();
52
- this.getComments();
53
- this.getAssignees();
54
- this.getExternalSync();
55
- }
56
- }
57
-
58
- getJob = async () => {
59
- try {
60
- const res = await maintenanceActions.getJob(this.props.auth.site, this.state.jobId);
61
- this.setState({ updating: false });
62
- res.data.location = res.data.site;
63
- this.setJob(res.data);
64
- } catch (error) {
65
- console.error('getJob', error);
66
- }
67
- };
68
-
69
- getAssignees = async () => {
70
- try {
71
- const res = await maintenanceActions.getAssignees(this.props.auth.site);
72
- this.setState({ assignees: res.data.Users });
73
- } catch (error) {
74
- console.error('getAssignees', error);
75
- }
76
- };
77
-
78
- getExternalSync = async () => {
79
- try {
80
- this.setState({ loadingExternalSync: true });
81
- const res = await maintenanceActions.getExternalSync(this.state.jobId);
82
- this.setState({ externalSync: res.data, loadingExternalSync: false });
83
- } catch (error) {
84
- // 404 is expected if no sync - don't show error
85
- if (error && error.response && error.response.status !== 404) {
86
- console.error('getExternalSync', error);
87
- }
88
- this.setState({ loadingExternalSync: false });
89
- }
90
- };
91
-
92
- onRetrySync = async () => {
93
- const { job } = this.state;
94
- if (!job || this.state.retryingSync) return;
95
-
96
- this.setState({ retryingSync: true, retrySyncError: null });
97
-
98
- try {
99
- await maintenanceActions.retrySync(job.id);
100
- // Refresh job data to get updated history
101
- await this.getJob();
102
- this.setState({ retryingSync: false, retrySyncInitiated: true });
103
- } catch (error) {
104
- console.error('onRetrySync', error);
105
- const errorMessage =
106
- (error && error.response && error.response.data && error.response.data.error) || 'Failed to retry sync. Please try again.';
107
- this.setState({ retryingSync: false, retrySyncError: errorMessage });
108
- }
109
- };
110
-
111
- getStatusType = (status) => {
112
- const { statusTypes } = this.props;
113
- let statusType = statusTypes.find((s) => s.text === status);
114
- if (!statusType) {
115
- const defaultStatus = statusTypes.find((s) => s.category === STATUS_NOT_ACTIONED);
116
- statusType = { ...defaultStatus, text: status };
117
- }
118
- return statusType;
119
- };
120
-
121
- setJob = (job) => {
122
- if (_.isEmpty(job.lastActivity)) {
123
- job.lastActivity = '-- --';
124
- job.noActivity = true;
125
- }
126
- if (_.isEmpty(job.status)) {
127
- job.status = 'Unassigned';
128
- job.notStatus = true;
129
- }
130
- if (_.isEmpty(job.audience)) {
131
- job.audience = [{ displayName: 'Unassigned', isEmpty: true }];
132
- }
133
- let needToMarkSeen = false;
134
- if (!job.seen) {
135
- job.seen = true;
136
- needToMarkSeen = true;
137
- }
138
- this.setState({ job }, () => {
139
- if (needToMarkSeen) this.markSeen();
140
- });
141
- this.props.jobsLoaded([job]);
142
- };
143
-
144
- editJob = () => {};
145
-
146
- isReadyToSaveNote = () => {
147
- const { noteAttachments, noteInput, noteImages } = this.state;
148
- if (_.some(noteAttachments, (n) => n.Uploading)) return false;
149
- return !_.isEmpty(noteInput) || !_.isEmpty(noteAttachments) || !_.isEmpty(noteImages);
150
- };
151
-
152
- onOpenAddNote = () => {
153
- this.setState({ addNoteOpen: true, editingNote: null });
154
- };
155
-
156
- onCloseAddNote = () => {
157
- const newState = { addNoteOpen: false, editingNote: null };
158
- if (!!this.state.editingNote) {
159
- newState.noteInput = '';
160
- newState.noteAttachments = [];
161
- newState.noteImages = [];
162
- }
163
- this.setState(newState);
164
- };
165
-
166
- onOpenNoteMenu = (index) => {
167
- if (this.state.noteMenuOpen === index) {
168
- this.setState({ noteMenuOpen: null });
169
- } else {
170
- this.setState({ noteMenuOpen: index });
171
- }
172
- };
173
-
174
- onHandlePDFFileChange = (event) => {
175
- const file = event.target.files[0];
176
- if (!file || this.state.uploadingNoteAttachment) return;
177
- const noteAttachments = [...this.state.noteAttachments];
178
- const newAttachment = {
179
- Uploading: true,
180
- Title: file.name,
181
- };
182
- noteAttachments.push(newAttachment);
183
- this.setState({
184
- noteAttachments,
185
- });
186
- Apis.fileActions
187
- .uploadMediaAsync(file, file.name)
188
- .then((fileRes) => {
189
- newAttachment.Source = fileRes;
190
- newAttachment.Uploading = false;
191
- this.setState({
192
- noteAttachments: [...this.state.noteAttachments],
193
- });
194
- })
195
- .catch((uploadErrorRes) => {
196
- console.log(uploadErrorRes);
197
- newAttachment.Uploading = false;
198
- this.setState({
199
- noteAttachments: [...this.state.noteAttachments],
200
- });
201
- });
202
- event.target.value = '';
203
- };
204
-
205
- onRemoveAttachment = (a) => {
206
- const index = this.state.noteAttachments.indexOf(a);
207
- if (index > -1) {
208
- const newAttachments = [...this.state.noteAttachments];
209
- newAttachments.splice(index, 1);
210
- this.setState({
211
- noteAttachments: newAttachments,
212
- });
213
- }
214
- };
215
-
216
- onShowSelectAssignee = () => {
217
- this.setState({
218
- showingAssigneeSelector: true,
219
- });
220
- };
221
-
222
- onCloseSelectAssignee = () => {
223
- this.setState({
224
- showingAssigneeSelector: false,
225
- });
226
- };
227
-
228
- onSelectAssignee = (user) => {
229
- this.setState({
230
- selectedAssignee: user,
231
- });
232
- };
233
-
234
- onConfirmAssignee = async () => {
235
- this.setState({
236
- confirmingAssignee: true,
237
- });
238
- try {
239
- if (this.state.selectedAssignee) {
240
- await this.onAssignUser(this.state.selectedAssignee.id);
241
- }
242
- this.onCloseSelectAssignee();
243
- } catch (error) {
244
- console.error('onConfirmAssignee', error);
245
- }
246
- this.setState({
247
- confirmingAssignee: false,
248
- });
249
- };
250
-
251
- // Method to handle user assignment
252
- onAssignUser = async (userId) => {
253
- try {
254
- const res = await maintenanceActions.assignJob(this.state.jobId, userId);
255
- this.getJob();
256
- } catch (err) {
257
- console.error('onAssignUser', err);
258
- }
259
- };
260
-
261
- onConfirmAddNote = async () => {
262
- if (!this.isReadyToSaveNote()) return;
263
-
264
- try {
265
- this.setState({ submittingNote: true });
266
- const res = await (this.state.editingNote
267
- ? maintenanceActions.editNote(
268
- this.state.jobId,
269
- this.state.editingNote,
270
- this.state.noteInput,
271
- this.state.noteAttachments.map((a) => {
272
- return {
273
- Title: a.Title,
274
- Source: a.Source,
275
- };
276
- }),
277
- this.state.noteImages,
278
- )
279
- : maintenanceActions.addNote(
280
- this.state.jobId,
281
- this.state.noteInput,
282
- this.state.noteAttachments.map((a) => {
283
- return {
284
- Title: a.Title,
285
- Source: a.Source,
286
- };
287
- }),
288
- this.state.noteImages,
289
- ));
290
- this.setState(
291
- {
292
- job: res.data.job,
293
- submittingNote: false,
294
- addNoteOpen: false,
295
- noteInput: '',
296
- noteAttachments: [],
297
- noteImages: [],
298
- editingNote: null,
299
- },
300
- () => {
301
- this.props.jobsLoaded([this.state.job]);
302
- },
303
- );
304
- } catch (err) {
305
- console.error('onConfirmAddNote', err);
306
- }
307
- };
308
-
309
- onDeleteNote = (n) => {
310
- if (!window.confirm(values.textAreYouSureYouWantToDeleteNote)) {
311
- this.setState({ noteMenuOpen: null });
312
- return;
313
- }
314
-
315
- maintenanceActions.deleteNote(this.state.jobId, n.Id);
316
- const newNotes = _.filter(this.state.job.Notes, (note) => note.Id !== n.Id);
317
- const newJob = { ...this.state.job };
318
- newJob.Notes = newNotes;
319
- this.setState({ job: newJob, noteMenuOpen: null });
320
- };
321
-
322
- onOpenEditNote = (n) => {
323
- this.setState(
324
- {
325
- noteAttachments: n.Attachments || [],
326
- noteImages: n.Images || [],
327
- noteInput: n.Note || '',
328
- addNoteOpen: true,
329
- editingNote: n.Id,
330
- noteMenuOpen: null,
331
- },
332
- this.checkSetImage,
333
- );
334
- };
335
-
336
- markSeen = () => {
337
- const { job } = this.state;
338
- const { auth } = this.props;
339
- // Must have maintenance permission and not the requester
340
- if (!Session.validateAccess(auth.site, values.permissionMaintenanceTracking, auth)) return;
341
- if (auth.user.Id === job.userID) return;
342
-
343
- this.setState({ updating: true }, async () => {
344
- try {
345
- const update = {
346
- id: job.id,
347
- seen: true,
348
- status: job.status || 'Unassigned',
349
- };
350
-
351
- await maintenanceActions.editJob(update, auth.site);
352
- } catch (error) {
353
- this.setState({ updating: false });
354
- console.log('markSeen error', error);
355
- alert('Something went wrong with the request. Please try again.');
356
- }
357
- });
358
- };
359
-
360
- getComments = () => {
361
- reactionActions.getComments(this.state.jobId, values.commentKey, 0).then((res) => {
362
- this.setState({ comments: res.data });
363
- });
364
- };
365
-
366
- checkSetImage() {
367
- if (this.imageInput && !_.isEmpty(this.state.noteImages)) {
368
- this.imageInput.getWrappedInstance().setValue(this.state.noteImages);
369
- } else {
370
- setTimeout(this.checkSetImage, 100);
371
- }
372
- }
373
-
374
- onAddComment = async () => {
375
- const { commentInput, jobId, job, comments } = this.state;
376
- try {
377
- this.setState({ commentInput: '' });
378
- const res = await reactionActions.addComment(jobId, values.commentKey, job.title, job.site, commentInput);
379
- this.setState({ comments: [...comments, res.data] });
380
- } catch (error) {
381
- console.error('onAddComment', error);
382
- }
383
- };
384
-
385
- onHandleChange = (event) => {
386
- var stateChange = {};
387
- stateChange[event.target.getAttribute('id')] = event.target.value;
388
- this.setState(stateChange);
389
- };
390
-
391
- onTogglePriorityChanger = () => {
392
- this.setState({ priorityChangerOpen: !this.state.priorityChangerOpen });
393
- };
394
-
395
- onSelectPriority = async (priority) => {
396
- this.setState({
397
- job: { ...this.state.job, priority },
398
- priorityChangerOpen: false,
399
- });
400
-
401
- try {
402
- const res = await maintenanceActions.editJobPriority(this.state.job.id, priority);
403
- const { job } = res.data;
404
- this.props.jobsLoaded([job]);
405
- this.setState({ job });
406
- } catch (error) {
407
- console.error('onSelectPriority', error);
408
- }
409
- };
410
-
411
- onToggleStatusChanger = () => {
412
- this.setState({ statusChangerOpen: !this.state.statusChangerOpen });
413
- };
414
-
415
- onSelectStatus = async (status) => {
416
- this.setState({
417
- job: {
418
- ...this.state.job,
419
- status: status,
420
- },
421
- statusChangerOpen: false,
422
- });
423
-
424
- try {
425
- const res = await maintenanceActions.editJobStatus(this.state.job.id, status);
426
- const { job } = res.data;
427
- this.props.jobsLoaded([job]);
428
- this.setState({ job });
429
- } catch (error) {
430
- console.error('onSelectStatus', error);
431
- }
432
- };
433
-
434
- renderPriorityChanger() {
435
- if (!this.state.priorityChangerOpen) return null;
436
- return (
437
- <div className="statusChanger statusChanger-priority">
438
- {jobPriorityOptions.map((p) => {
439
- return (
440
- <div key={p.name} className="statusLabel" onClick={() => this.onSelectPriority(p.name)} style={{ backgroundColor: p.color }}>
441
- <span className="statusLabel_text">{p.name}</span>
442
- </div>
443
- );
444
- })}
445
- </div>
446
- );
447
- }
448
-
449
- renderPriorityLabel() {
450
- const { auth } = this.props;
451
- if (!Session.validateAccess(auth.site, values.permissionMaintenanceTracking, auth)) return null;
452
-
453
- const { job } = this.state;
454
- if (!job) return null;
455
-
456
- const selectedPriority = getJobPriority(job.priority);
457
- return (
458
- <div
459
- className="statusLabel marginTop-5 pointer"
460
- onClick={this.onTogglePriorityChanger}
461
- style={{ backgroundColor: selectedPriority.color }}
462
- >
463
- <span className="statusLabel_text">{job.priority || selectedPriority.name}</span>
464
- {this.renderPriorityChanger()}
465
- </div>
466
- );
467
- }
468
-
469
- renderStatusLabel() {
470
- if (!this.state.job.status) return null;
471
- const statusType = this.getStatusType(this.state.job.status);
472
- const { auth } = this.props;
473
- if (Session.validateAccess(auth.site, values.permissionMaintenanceTracking, auth)) {
474
- return (
475
- <div className="statusLabel pointer" onClick={this.onToggleStatusChanger} style={{ backgroundColor: statusType.color }}>
476
- <span className="statusLabel_text">{statusType.text}</span>
477
- {this.renderStatusChanger()}
478
- </div>
479
- );
480
- }
481
- return (
482
- <div className="statusLabel" style={{ backgroundColor: statusType.color }}>
483
- <span className="statusLabel_text">{statusType.text}</span>
484
- </div>
485
- );
486
- }
487
-
488
- renderNotesButton() {
489
- const { auth } = this.props;
490
- if (!Session.validateAccess(auth.site, values.permissionMaintenanceTracking, auth)) return null;
491
- return (
492
- <div
493
- className="statusLabel pointer"
494
- onClick={this.onOpenAddNote}
495
- style={{ backgroundColor: Config.env.colourBrandingMain, marginLeft: 8 }}
496
- >
497
- <span className="statusLabel_text">Add Note</span>
498
- </div>
499
- );
500
- }
501
-
502
- renderAssignButton() {
503
- const { auth } = this.props;
504
- if (!Session.validateAccess(auth.site, values.permissionMaintenanceTracking, auth)) return null;
505
- return (
506
- <div
507
- className="statusLabel pointer"
508
- onClick={this.onShowSelectAssignee}
509
- style={{ backgroundColor: Config.env.colourBrandingMain, marginLeft: 8 }}
510
- >
511
- <span className="statusLabel_text">Assign</span>
512
- </div>
513
- );
514
- }
515
-
516
- renderStatusChanger() {
517
- if (!this.state.statusChangerOpen) return null;
518
- const { statusTypes } = this.props;
519
-
520
- return (
521
- <div className="statusChanger statusChanger-maintenance">
522
- {statusTypes.map((status) => {
523
- return (
524
- <div
525
- key={status.text}
526
- className="statusLabel"
527
- onClick={() => this.onSelectStatus(status.text)}
528
- style={{ backgroundColor: status.color }}
529
- >
530
- <span className="statusLabel_text">{status.text}</span>
531
- </div>
532
- );
533
- })}
534
- </div>
535
- );
536
- }
537
-
538
- renderComment(c) {
539
- return <Components.Comment key={c.Id} comment={c} />;
540
- return (
541
- <div key={c.Id} className="comment">
542
- <p className="comment_text">{Helper.toParagraphed(c.Comment)}</p>
543
- <div className="comment_bottom">
544
- <Components.ProfilePic className="comment_profilePic" size={25} image={c.User.profilePic} />
545
- <p className="comment_name">{c.User.displayName}</p>
546
- <p className="comment_time">{moment.utc(c.Timestamp).local().format('D MMM YYYY • h:mma')}</p>
547
- </div>
548
- </div>
549
- );
550
- }
551
-
552
- renderCommentSection() {
553
- if (this.state.loadingComments) return null;
554
-
555
- return (
556
- <div className="padding-60 paddingLeft-20">
557
- <div className="newTopBar paddingLeft-40">
558
- <Components.Text type="formTitleSmall" className="marginBottom-16">
559
- Comments
560
- </Components.Text>
561
- <div className="commentSection">{this.state.comments.map((c) => this.renderComment(c))}</div>
562
- <div className="commentReply">
563
- <div
564
- className={`commentReply_button${!_.isEmpty(this.state.commentInput) ? ' commentReply_button-active' : ''}`}
565
- onClick={this.onAddComment}
566
- >
567
- <FontAwesome className="commentReply_icon" name="paper-plane-o" />
568
- </div>
569
- <Textarea
570
- id="commentInput"
571
- placeholder="Reply here..."
572
- type="text"
573
- className="commentReply_input"
574
- value={this.state.commentInput}
575
- onChange={(e) => this.onHandleChange(e)}
576
- />
577
- </div>
578
- </div>
579
- </div>
580
- );
581
- }
582
-
583
- renderImageGrid(images, size = undefined) {
584
- const imagesToUse = images && images.length > 0 ? images : [];
585
- return (
586
- <div className="imageGrid">
587
- {imagesToUse.map((image, i) => {
588
- return (
589
- <a href={image} target="_blank" rel="noopener noreferrer" key={i}>
590
- <div
591
- className="imageGrid_image"
592
- style={{ backgroundImage: `url('${Helper.get1400(image)}')`, width: size, height: size }}
593
- ></div>
594
- </a>
595
- );
596
- })}
597
- </div>
598
- );
599
- }
600
-
601
- renderDocumentGrid(documents) {
602
- const documentsToUse = documents && documents.length > 0 ? documents : [];
603
- return (
604
- <div className="documentGrid">
605
- {documentsToUse.map((doc, index) => (
606
- <Components.Attachment key={index} uploading={doc.uploading} source={doc.url} title={doc.name} />
607
- ))}
608
- </div>
609
- );
610
- }
611
-
612
- renderImages() {
613
- if (_.isEmpty(this.state.job.image) && _.isEmpty(this.state.job.images)) return null;
614
-
615
- const imagesToUse = _.isEmpty(this.state.job.image) ? this.state.job.images : [this.state.job.image];
616
- return (
617
- <div className="padding-60 paddingVertical-40 bottomDivideBorder">
618
- <Components.Text type="formTitleSmall" className="marginBottom-16">
619
- Images
620
- </Components.Text>
621
- {this.renderImageGrid(imagesToUse)}
622
- </div>
623
- );
624
- }
625
-
626
- renderCustomFields() {
627
- const { job } = this.state;
628
- const { customFields } = job;
629
-
630
- const labelClass = 'fieldLabel';
631
- const answerClass = 'fontRegular fontSize-16 text-dark marginTop-5';
632
-
633
- const renderAnswer = (field) => {
634
- switch (field.type) {
635
- case 'date':
636
- return <div className={answerClass}>{field.answer ? moment(field.answer, 'YYYY-MM-DD').format('DD-MMM-YYYY') : ''}</div>;
637
- case 'time':
638
- return <div className={answerClass}>{field.answer ? moment(field.answer, 'HH:mm').format('h:mm a') : ''}</div>;
639
- case 'yn':
640
- return <div className={answerClass}>{field.answer ? 'Yes' : 'No'}</div>;
641
- case 'checkbox':
642
- return <div className={answerClass}>{field.answer && Array.isArray(field.answer) ? field.answer.join(', ') : ''}</div>;
643
- case 'image':
644
- return this.renderImageGrid(field.answer);
645
- case 'document':
646
- return this.renderDocumentGrid(field.answer);
647
- default:
648
- return <div className={answerClass}>{field.answer}</div>;
649
- }
650
- };
651
-
652
- return (
653
- <div className="padding-60 paddingVertical-40 bottomDivideBorder">
654
- {customFields.map((field, index) => {
655
- if (['staticTitle', 'staticText'].includes(field.type)) return null;
656
- if (_.isNil(field.answer) || field.answer === '' || field.answer.length === 0) return null;
657
- return (
658
- <div key={index} className="marginTop-16">
659
- <div className={labelClass}>{field.label}</div>
660
- {renderAnswer(field)}
661
- </div>
662
- );
663
- })}
664
- </div>
665
- );
666
- }
667
-
668
- renderInner() {
669
- if (this.state.job == null) return null;
670
- const { customFields } = this.state.job;
671
- const hasCustomFields = customFields && customFields.length > 0;
672
-
673
- return (
674
- <div style={{ paddingBottom: 40 }}>
675
- <div className="padding-60 paddingVertical-40 bottomDivideBorder relative">
676
- <Components.Text type="formTitleLarge" className="marginBottom-8">
677
- {this.state.job.title || values.textSingularName}
678
- </Components.Text>
679
- <Components.Text type="formTitleMedium" className="marginBottom-24">
680
- {values.textEntityName} #{this.state.job.jobId}
681
- </Components.Text>
682
- <div className="marginTop-16">
683
- <div className={'fieldLabel'}>Submission date</div>
684
- <div className={'fontRegular fontSize-16 text-dark marginTop-5'}>
685
- {moment.utc(this.state.job.createdTime).local().format('D MMM YY')}
686
- </div>
687
- </div>
688
- <div className="marginTop-16">
689
- <div className={'fieldLabel'}>Type</div>
690
- <div className={'fontRegular fontSize-16 text-dark marginTop-5'}>{this.state.job.type}</div>
691
- </div>
692
- <div className="marginTop-16">
693
- <div className={'fieldLabel'}>Address</div>
694
- <div className={'fontRegular fontSize-16 text-dark marginTop-5'}>{this.state.job.room}</div>
695
- </div>
696
- {hasCustomFields ? null : (
697
- <div className="marginTop-16">
698
- <div className={'fieldLabel'}>Description {this.state.job.image ? '- (image supplied)' : ''}</div>
699
- <div className={'fontRegular fontSize-16 text-dark marginTop-5'}>{this.state.job.description}</div>
700
- </div>
701
- )}
702
- </div>
703
- <div className="padding-60 paddingVertical-40 bottomDivideBorder">
704
- <Components.Text type="formTitleSmall" className="marginBottom-16">
705
- Contact Details
706
- </Components.Text>
707
- <div className="marginTop-16">
708
- <div className={'fieldLabel'}>Name</div>
709
- <div className={'fontRegular fontSize-16 text-dark marginTop-5'}>{this.state.job.userName}</div>
710
- </div>
711
- <div className="marginTop-16">
712
- <div className={'fieldLabel'}>Contact number</div>
713
- <div className={'fontRegular fontSize-16 text-dark marginTop-5'}>
714
- {_.isEmpty(this.state.job.phone) ? 'No phone provided' : this.state.job.phone}
715
- </div>
716
- </div>
717
- {hasCustomFields ? null : (
718
- <div>
719
- <div className="marginTop-16">
720
- <div className={'fieldLabel'}>Should person be home?</div>
721
- <div className={'fontRegular fontSize-16 text-dark marginTop-5'}>{this.state.job.isHome ? 'Yes' : 'No'}</div>
722
- </div>
723
- {this.state.job.isHome && this.state.job.homeText && (
724
- <div className="marginTop-16">
725
- <div className={'fieldLabel'}>When</div>
726
- <div className={'fontRegular fontSize-16 text-dark marginTop-5'}>{this.state.job.homeText}</div>
727
- </div>
728
- )}
729
- </div>
730
- )}
731
- </div>
732
- {hasCustomFields ? null : this.renderImages()}
733
- {hasCustomFields ? this.renderCustomFields() : null}
734
- {this.renderCommentSection()}
735
- </div>
736
- );
737
- }
738
-
739
- renderHistoryEntry(e, i) {
740
- const { job } = this.state;
741
- const entryToUse = e || {
742
- timestamp: job.createdTime,
743
- status: 'Unassigned',
744
- user: {
745
- displayName: job.userName,
746
- id: job.userID,
747
- profilePic: job.userProfilePic,
748
- },
749
- };
750
- const statusType = this.getStatusType(entryToUse.status);
751
- return (
752
- <div className="ticketHistoryEntry" key={i}>
753
- <p className="ticketHistoryEntry_timestamp">{moment.utc(entryToUse.timestamp).local().format('D MMM YYYY h:mma')}</p>
754
- <div className="statusLabel statusLabel-large statusLabel-full" style={{ backgroundColor: statusType.color }}>
755
- <span className="statusLabel_text">
756
- {e ? `${entryToUse.user.displayName} marked as ${statusType.text}` : `${values.textEntityName} opened`}
757
- </span>
758
- </div>
759
- </div>
760
- );
761
- }
762
-
763
- renderNote(note, index) {
764
- return (
765
- <div className="ticketHistoryEntry" key={index}>
766
- <p className="ticketHistoryEntry_timestamp">{moment.utc(note.Timestamp).local().format('D MMM YYYY h:mma')}</p>
767
- <div className="statusLabel statusLabel-large statusLabel-full" style={{ backgroundColor: '#6e79c5' }}>
768
- <span className="statusLabel_text">Staff Notes</span>
769
- </div>
770
- <div className="maintenanceNote">
771
- <div className="maintenanceNote_top">
772
- {this.props.auth && this.props.auth.user && this.props.auth.user.Id === note.User.id && (
773
- <Components.SVGIcon
774
- colour={Colours.COLOUR_DUSK_LIGHT}
775
- icon="more15"
776
- className="maintenanceNote_moreIcon"
777
- onClick={() => this.onOpenNoteMenu(index)}
778
- />
779
- )}
780
- <p className="maintenanceNote_name">{note.User.displayName}</p>
781
- {this.state.noteMenuOpen === index && (
782
- <Components.MoreMenu
783
- options={[
784
- {
785
- key: 'edit',
786
- text: 'Edit',
787
- onPress: () => this.onOpenEditNote(note),
788
- },
789
- {
790
- key: 'delete',
791
- text: 'Delete',
792
- onPress: () => this.onDeleteNote(note),
793
- },
794
- ]}
795
- />
796
- )}
797
- </div>
798
- <p className="maintenanceNote_text">{Helper.toParagraphed(note.Note)}</p>
799
- {note.Attachments.map((a, i) => this.renderAttachment(a, i))}
800
- {note.Images && note.Images.length > 0 ? this.renderImageGrid(note.Images, IMAGE_SIZE_NOTE) : null}
801
- </div>
802
- </div>
803
- );
804
- }
805
-
806
- renderAssignment() {
807
- const { job } = this.state;
808
- if (!job) return null;
809
-
810
- return (
811
- <div className="padding-32 paddingVertical-40 bottomDivideBorder relative">
812
- <div className="newTopBar clearfix flex flex-reverse">
813
- {this.renderAssignButton()}
814
- <Components.Text type="formTitleSmall" className="flex-1">
815
- Assignment
816
- </Components.Text>
817
- </div>
818
- <div>
819
- <div className="marginTop-16">
820
- <div className={'fieldLabel'}>Assigned to</div>
821
- <div className={'fontRegular fontSize-16 text-dark marginTop-5'}>
822
- {job.Assignee ? <Components.UserListing user={job.Assignee} /> : 'Unassigned'}
823
- </div>
824
- </div>
825
- </div>
826
- </div>
827
- );
828
- }
829
-
830
- renderAssignmentEntry(e, i) {
831
- return (
832
- <div className="ticketHistoryEntry" key={i}>
833
- <p className="ticketHistoryEntry_timestamp">{moment.utc(e.timestamp).local().format('D MMM YYYY h:mma')}</p>
834
- <div className="statusLabel statusLabel-large statusLabel-full" style={{ backgroundColor: Colours.COLOUR_DUSK }}>
835
- <span className="statusLabel_text">
836
- {e.user.displayName} assigned the {values.textSingularName} to {e.assignedUser ? e.assignedUser.displayName : 'Unassigned'}
837
- </span>
838
- </div>
839
- </div>
840
- );
841
- }
842
-
843
- renderExternalSyncEntry(e, i) {
844
- const isSuccess = e.EntryType === 'ExternalIDSet';
845
- const backgroundColor = isSuccess ? Colours.COLOUR_GREEN : Colours.COLOUR_RED; // Green for success, red for failure
846
-
847
- return (
848
- <div className="ticketHistoryEntry" key={i}>
849
- <p className="ticketHistoryEntry_timestamp">{moment.utc(e.timestamp).local().format('D MMM YYYY h:mma')}</p>
850
- <div className="statusLabel statusLabel-large statusLabel-full" style={{ backgroundColor }}>
851
- <span className="statusLabel_text">
852
- {isSuccess
853
- ? `Synced to ${e.systemType || 'external system'}${e.externalId ? ` (ID: ${e.externalId})` : ''}`
854
- : `Failed to sync to ${e.systemType || 'external system'}${e.error ? `: ${e.error}` : ''}`}
855
- </span>
856
- </div>
857
- </div>
858
- );
859
- }
860
-
861
- renderPriority() {
862
- const { auth } = this.props;
863
- if (!Session.validateAccess(auth.site, values.permissionMaintenanceTracking, auth)) return null;
864
-
865
- return (
866
- <div className="padding-32 paddingVertical-40 bottomDivideBorder relative">
867
- <div className="newTopBar clearfix flex flex-reverse">
868
- {this.renderPriorityLabel()}
869
- <Components.Text type="formTitleSmall" className="flex-1">
870
- Priority
871
- </Components.Text>
872
- </div>
873
- </div>
874
- );
875
- }
876
-
877
- hasSyncFailureWithoutSuccess() {
878
- const { job } = this.state;
879
- if (!job || !job.history) return false;
880
-
881
- const history = job.history || [];
882
- const hasSuccess = history.some((entry) => entry.EntryType === 'ExternalIDSet');
883
- const hasFailure = history.some((entry) => entry.EntryType === 'ExternalIDSetFailed');
884
-
885
- return hasFailure && !hasSuccess;
886
- }
887
-
888
- renderRetrySyncButton() {
889
- const { auth } = this.props;
890
- const { retryingSync, retrySyncInitiated } = this.state;
891
-
892
- // Only show for users with maintenance tracking permission
893
- if (!Session.validateAccess(auth.site, values.permissionMaintenanceTracking, auth)) return null;
894
-
895
- // Only show if there's a failure without success and retry hasn't been initiated
896
- if (!this.hasSyncFailureWithoutSuccess() || retrySyncInitiated) return null;
897
-
898
- // Show spinner while retrying
899
- if (retryingSync) {
900
- return <FontAwesome style={{ fontSize: 20, color: Colours.COLOUR_DUSK_LIGHT, marginLeft: 8 }} name="spinner fa-pulse fa-fw" />;
901
- }
902
-
903
- return (
904
- <div className="statusLabel pointer" onClick={this.onRetrySync} style={{ backgroundColor: Colours.COLOUR_RED, marginLeft: 8 }}>
905
- <span className="statusLabel_text">Retry Sync</span>
906
- </div>
907
- );
908
- }
909
-
910
- renderExternalSyncStatus() {
911
- const { retrySyncError, retrySyncInitiated } = this.state;
912
-
913
- // Show error message if retry failed
914
- if (retrySyncError) {
915
- return (
916
- <Components.Text type="body">
917
- <FontAwesome className="userStatusIcon" name="times-circle" style={{ color: Colours.COLOUR_RED }} /> {retrySyncError}
918
- </Components.Text>
919
- );
920
- }
921
-
922
- // Show success message if retry was initiated
923
- if (retrySyncInitiated) {
924
- return (
925
- <Components.Text type="body">
926
- <FontAwesome className="userStatusIcon" name="check-circle" style={{ color: Colours.COLOUR_GREEN }} /> Sync retry initiated. Check
927
- back shortly for results.
928
- </Components.Text>
929
- );
930
- }
931
-
932
- // Show failure message with instruction
933
- if (this.hasSyncFailureWithoutSuccess()) {
934
- return (
935
- <Components.Text type="body">
936
- <FontAwesome className="userStatusIcon" name="times-circle" style={{ color: Colours.COLOUR_RED }} /> External sync failed. Use the
937
- retry button to attempt again.
938
- </Components.Text>
939
- );
940
- }
941
-
942
- return null;
943
- }
944
-
945
- renderExternalSync() {
946
- const { externalSync, loadingExternalSync } = this.state;
947
-
948
- // Check if we should show this section at all
949
- const hasExternalSyncData = externalSync && !loadingExternalSync;
950
- const hasSyncFailure = this.hasSyncFailureWithoutSuccess();
951
-
952
- // Show section if we have sync data OR if there's a failure that can be retried
953
- if (!hasExternalSyncData && !hasSyncFailure) return null;
954
-
955
- return (
956
- <div className="padding-32 paddingVertical-40 bottomDivideBorder relative">
957
- <div className="newTopBar clearfix flex flex-reverse">
958
- {this.renderRetrySyncButton()}
959
- <Components.Text type="formTitleSmall" className="flex-1">
960
- External Sync
961
- </Components.Text>
962
- </div>
963
- <div className="marginTop-16">
964
- {hasExternalSyncData ? (
965
- <>
966
- {externalSync.systemType && (
967
- <Components.Text type="body" className="marginBottom-8">
968
- <strong>System:</strong> {externalSync.systemType}
969
- </Components.Text>
970
- )}
971
- {externalSync.externalId && (
972
- <Components.Text type="body" className="marginBottom-8">
973
- <strong>External ID:</strong> {externalSync.externalId}
974
- </Components.Text>
975
- )}
976
- {externalSync.syncedAt && (
977
- <Components.Text type="body" className="marginBottom-8">
978
- <strong>Synced:</strong> {moment.utc(externalSync.syncedAt).local().format('D MMM YYYY h:mma')}
979
- </Components.Text>
980
- )}
981
- </>
982
- ) : (
983
- this.renderExternalSyncStatus()
984
- )}
985
- </div>
986
- </div>
987
- );
988
- }
989
-
990
- renderOverview() {
991
- const { job } = this.state;
992
- if (!job || !job.history) return null;
993
-
994
- const source = _.sortBy(
995
- [
996
- ...job.history.map((e) => {
997
- return { ...e, EntryType: e.EntryType || 'status' };
998
- }),
999
- ...(job.Notes || []).map((e) => {
1000
- return { ...e, timestamp: e.Timestamp, EntryType: 'note' };
1001
- }),
1002
- ],
1003
- 'timestamp',
1004
- );
1005
-
1006
- return (
1007
- <div className="padding-32 paddingVertical-40 bottomDivideBorder relative">
1008
- <div className="newTopBar clearfix flex flex-reverse">
1009
- {this.renderNotesButton()}
1010
- {this.renderStatusLabel()}
1011
- <Components.Text type="formTitleSmall" className="flex-1">
1012
- Status History
1013
- </Components.Text>
1014
- </div>
1015
- {this.renderHistoryEntry(null, -1)}
1016
- {_.map(source, (e, i) => {
1017
- switch (e.EntryType) {
1018
- case 'status':
1019
- return this.renderHistoryEntry(e, i);
1020
- case 'note':
1021
- return this.renderNote(e, i);
1022
- case 'assignment':
1023
- return this.renderAssignmentEntry(e, i);
1024
- case 'ExternalIDSet':
1025
- case 'ExternalIDSetFailed':
1026
- return this.renderExternalSyncEntry(e, i);
1027
- default:
1028
- break;
1029
- }
1030
- })}
1031
- </div>
1032
- );
1033
- }
1034
-
1035
- renderButtons() {
1036
- return (
1037
- <div>
1038
- <Components.Button
1039
- inline
1040
- buttonType="tertiary"
1041
- onClick={() => {
1042
- window.history.back();
1043
- }}
1044
- isActive
1045
- style={{ marginRight: 16 }}
1046
- >
1047
- Back
1048
- </Components.Button>
1049
- {Session.validateAccess(this.props.auth.site, values.permissionMaintenanceTracking, this.props.auth) &&
1050
- !_.isEmpty(this.state.job) && (
1051
- <Link to={`${values.routeAddRequest}/${this.state.jobId}`}>
1052
- <Components.Button inline style={{ marginRight: 25 }} buttonType="outlined" isActive onClick={this.editJob}>
1053
- Edit Details
1054
- </Components.Button>
1055
- </Link>
1056
- )}
1057
- </div>
1058
- );
1059
- }
1060
-
1061
- renderAttachment(attachment, index, onRemove) {
1062
- if (!attachment) return null;
1063
-
1064
- return (
1065
- <Components.Attachment
1066
- key={index}
1067
- uploading={attachment.Uploading}
1068
- source={attachment.Source}
1069
- title={attachment.Title}
1070
- onRemove={onRemove ? () => onRemove(attachment) : undefined}
1071
- />
1072
- );
1073
- }
1074
-
1075
- renderAddNotePopup() {
1076
- if (!this.state.addNoteOpen) return null;
1077
-
1078
- if (this.state.submittingNote) {
1079
- return (
1080
- <Components.Popup title="Saving Note" maxWidth={600} hasPadding>
1081
- <div className="flex flex-center-row">
1082
- <FontAwesome className="spinner" name="spinner fa-pulse fa-fw" />
1083
- </div>
1084
- </Components.Popup>
1085
- );
1086
- }
1087
-
1088
- return (
1089
- <Components.Popup
1090
- title={`${this.state.editingNote ? 'Edit' : 'Add'} Note`}
1091
- onClose={this.onCloseAddNote}
1092
- maxWidth={600}
1093
- hasPadding
1094
- buttons={[
1095
- {
1096
- type: 'primary',
1097
- onClick: this.onConfirmAddNote,
1098
- isActive: this.isReadyToSaveNote(),
1099
- text: 'Save',
1100
- },
1101
- {
1102
- type: 'tertiary',
1103
- onClick: this.onCloseAddNote,
1104
- isActive: true,
1105
- text: 'Cancel',
1106
- },
1107
- ]}
1108
- >
1109
- <Components.GenericInput
1110
- id="noteInput"
1111
- type="textarea"
1112
- componentClass="textarea"
1113
- value={this.state.noteInput}
1114
- placeholder="Enter note"
1115
- onChange={(e) => this.onHandleChange(e)}
1116
- inputStyle={{
1117
- width: 400,
1118
- }}
1119
- />
1120
- <Components.Text type="h5">Attachments</Components.Text>
1121
- {this.state.noteAttachments.map((a, i) => this.renderAttachment(a, i, this.onRemoveAttachment))}
1122
- <input
1123
- ref={(input) => (this.attachmentInput = input)}
1124
- id="attachmentInput"
1125
- type="file"
1126
- className="fileInput"
1127
- onChange={(e) => this.onHandlePDFFileChange(e)}
1128
- accept="application/pdf"
1129
- />
1130
- <div
1131
- className="iconTextButton marginBottom-16"
1132
- onClick={() => {
1133
- this.attachmentInput.click();
1134
- }}
1135
- >
1136
- <FontAwesome className="iconTextButton_icon" name="paperclip" />
1137
- <p className="iconTextButton_text">Add Attachment</p>
1138
- </div>
1139
- <Components.ImageInput
1140
- ref={(ref) => {
1141
- this.imageInput = ref;
1142
- }}
1143
- multiple
1144
- refreshCallback={(images) => {
1145
- this.setState({ noteImages: images });
1146
- }}
1147
- />
1148
- </Components.Popup>
1149
- );
1150
- }
1151
-
1152
- renderUsers() {
1153
- let content = null;
1154
- if (this.state.confirmingAssignee) {
1155
- content = (
1156
- <div className="flex flex-center-row">
1157
- <FontAwesome className="spinner" name="spinner fa-pulse fa-fw" />
1158
- </div>
1159
- );
1160
- } else if (this.state.selectedAssignee) {
1161
- content = (
1162
- <div>
1163
- <Components.UserListing
1164
- key={this.state.selectedAssignee.id}
1165
- user={this.state.selectedAssignee}
1166
- rightContent={
1167
- <Components.SVGIcon
1168
- className="removeIcon"
1169
- icon="close"
1170
- onClick={() => {
1171
- this.onSelectAssignee();
1172
- }}
1173
- colour={Colours.COLOUR_DUSK}
1174
- />
1175
- }
1176
- />
1177
- </div>
1178
- );
1179
- } else {
1180
- content = (
1181
- <div>
1182
- <Components.GenericInput
1183
- id="userSearch"
1184
- type="text"
1185
- // label="Search"
1186
- placeholder="Search name"
1187
- value={this.state.userSearch}
1188
- onChange={(e) => this.onHandleChange(e)}
1189
- alwaysShowLabel
1190
- />
1191
- {_.sortBy(this.state.assignees, (u) => u.displayName.toUpperCase())
1192
- .filter((u) => {
1193
- if (_.isEmpty(this.state.userSearch)) return true;
1194
- return u.displayName.toUpperCase().indexOf(this.state.userSearch.toUpperCase()) > -1;
1195
- })
1196
- .map((user) => {
1197
- return (
1198
- <Components.UserListing
1199
- key={user.id}
1200
- user={user}
1201
- onClick={() => {
1202
- this.onSelectAssignee(user);
1203
- }}
1204
- />
1205
- );
1206
- })}
1207
- </div>
1208
- );
1209
- }
1210
- return (
1211
- <div className="genericInputContainer">
1212
- <Components.Text type="formLabel">Select User</Components.Text>
1213
- {content}
1214
- </div>
1215
- );
1216
- }
1217
-
1218
- renderUserSelectionPopup() {
1219
- if (!this.state.showingAssigneeSelector) return null;
1220
- return (
1221
- <Components.Popup
1222
- title="Assign Job"
1223
- onClose={this.onCloseSelectAssignee}
1224
- maxWidth={600}
1225
- hasPadding
1226
- buttons={[
1227
- {
1228
- type: 'primary',
1229
- onClick: this.onConfirmAssignee,
1230
- isActive: !!this.state.selectedAssignee,
1231
- text: 'Confirm',
1232
- },
1233
- {
1234
- type: 'tertiary',
1235
- onClick: this.onCloseSelectAssignee,
1236
- isActive: true,
1237
- text: 'Cancel',
1238
- },
1239
- ]}
1240
- >
1241
- {this.renderUsers()}
1242
- </Components.Popup>
1243
- );
1244
- }
1245
-
1246
- render() {
1247
- return (
1248
- <Components.OverlayPage>
1249
- {this.renderAddNotePopup()}
1250
- {this.renderUserSelectionPopup()}
1251
- <Components.OverlayPageContents>
1252
- <Components.OverlayPageSection className="pageSectionWrapper--fixedPopupSize">{this.renderInner()}</Components.OverlayPageSection>
1253
- <Components.OverlayPageSection className="pageSectionWrapper--newPopupSide pageSectionWrapper--newPopupSide-fixedWidth">
1254
- {this.renderAssignment()}
1255
- {this.renderPriority()}
1256
- {this.renderExternalSync()}
1257
- {this.renderOverview()}
1258
- </Components.OverlayPageSection>
1259
- </Components.OverlayPageContents>
1260
- <Components.OverlayPageBottomButtons>{this.renderButtons()}</Components.OverlayPageBottomButtons>
1261
- </Components.OverlayPage>
1262
- );
1263
- }
23
+ constructor(props) {
24
+ super(props);
25
+ this.state = {
26
+ jobId: Helper.safeReadParams(props, "jobId")
27
+ ? props.match.params.jobId
28
+ : null,
29
+ job: null,
30
+ showingSelector: false,
31
+ updating: false,
32
+ comments: [],
33
+ commentInput: "",
34
+ loadingComments: false,
35
+ priorityChangerOpen: false,
36
+ statusChangerOpen: false,
37
+ addNoteOpen: false,
38
+ noteAttachments: [],
39
+ noteInput: "",
40
+ noteImages: [],
41
+ assignees: [],
42
+ externalSync: null,
43
+ loadingExternalSync: false,
44
+ retryingSync: false,
45
+ retrySyncError: null,
46
+ retrySyncInitiated: false,
47
+ };
48
+ }
49
+
50
+ UNSAFE_componentWillReceiveProps(nextProps) {
51
+ Session.checkLoggedIn(this, this.props.auth);
52
+ }
53
+
54
+ componentDidMount() {
55
+ this.props.jobStatusesUpdate(this.props.auth.site);
56
+ if (this.state.jobId) {
57
+ this.getJob();
58
+ this.getComments();
59
+ this.getAssignees();
60
+ this.getExternalSync();
61
+ }
62
+ }
63
+
64
+ getJob = async () => {
65
+ try {
66
+ const res = await maintenanceActions.getJob(
67
+ this.props.auth.site,
68
+ this.state.jobId,
69
+ );
70
+ this.setState({ updating: false });
71
+ res.data.location = res.data.site;
72
+ this.setJob(res.data);
73
+ } catch (error) {
74
+ console.error("getJob", error);
75
+ }
76
+ };
77
+
78
+ getAssignees = async () => {
79
+ try {
80
+ const res = await maintenanceActions.getAssignees(this.props.auth.site);
81
+ this.setState({ assignees: res.data.Users });
82
+ } catch (error) {
83
+ console.error("getAssignees", error);
84
+ }
85
+ };
86
+
87
+ getExternalSync = async () => {
88
+ try {
89
+ this.setState({ loadingExternalSync: true });
90
+ const res = await maintenanceActions.getExternalSync(this.state.jobId);
91
+ this.setState({ externalSync: res.data, loadingExternalSync: false });
92
+ } catch (error) {
93
+ // 404 is expected if no sync - don't show error
94
+ if (error && error.response && error.response.status !== 404) {
95
+ console.error("getExternalSync", error);
96
+ }
97
+ this.setState({ loadingExternalSync: false });
98
+ }
99
+ };
100
+
101
+ onRetrySync = async () => {
102
+ const { job } = this.state;
103
+ if (!job || this.state.retryingSync) return;
104
+
105
+ this.setState({ retryingSync: true, retrySyncError: null });
106
+
107
+ try {
108
+ await maintenanceActions.retrySync(job.id);
109
+ // Refresh job data to get updated history
110
+ await this.getJob();
111
+ this.setState({ retryingSync: false, retrySyncInitiated: true });
112
+ } catch (error) {
113
+ console.error("onRetrySync", error);
114
+ const errorMessage =
115
+ (error &&
116
+ error.response &&
117
+ error.response.data &&
118
+ error.response.data.error) ||
119
+ "Failed to retry sync. Please try again.";
120
+ this.setState({ retryingSync: false, retrySyncError: errorMessage });
121
+ }
122
+ };
123
+
124
+ getStatusType = (status) => {
125
+ const { statusTypes } = this.props;
126
+ let statusType = statusTypes.find((s) => s.text === status);
127
+ if (!statusType) {
128
+ const defaultStatus = statusTypes.find(
129
+ (s) => s.category === STATUS_NOT_ACTIONED,
130
+ );
131
+ statusType = { ...defaultStatus, text: status };
132
+ }
133
+ return statusType;
134
+ };
135
+
136
+ setJob = (job) => {
137
+ if (_.isEmpty(job.lastActivity)) {
138
+ job.lastActivity = "-- --";
139
+ job.noActivity = true;
140
+ }
141
+ if (_.isEmpty(job.status)) {
142
+ job.status = "Unassigned";
143
+ job.notStatus = true;
144
+ }
145
+ if (_.isEmpty(job.audience)) {
146
+ job.audience = [{ displayName: "Unassigned", isEmpty: true }];
147
+ }
148
+ let needToMarkSeen = false;
149
+ if (!job.seen) {
150
+ job.seen = true;
151
+ needToMarkSeen = true;
152
+ }
153
+ this.setState({ job }, () => {
154
+ if (needToMarkSeen) this.markSeen();
155
+ });
156
+ this.props.jobsLoaded([job]);
157
+ };
158
+
159
+ editJob = () => {};
160
+
161
+ isReadyToSaveNote = () => {
162
+ const { noteAttachments, noteInput, noteImages } = this.state;
163
+ if (_.some(noteAttachments, (n) => n.Uploading)) return false;
164
+ return (
165
+ !_.isEmpty(noteInput) ||
166
+ !_.isEmpty(noteAttachments) ||
167
+ !_.isEmpty(noteImages)
168
+ );
169
+ };
170
+
171
+ onOpenAddNote = () => {
172
+ this.setState({ addNoteOpen: true, editingNote: null });
173
+ };
174
+
175
+ onCloseAddNote = () => {
176
+ const newState = { addNoteOpen: false, editingNote: null };
177
+ if (!!this.state.editingNote) {
178
+ newState.noteInput = "";
179
+ newState.noteAttachments = [];
180
+ newState.noteImages = [];
181
+ }
182
+ this.setState(newState);
183
+ };
184
+
185
+ onOpenNoteMenu = (index) => {
186
+ if (this.state.noteMenuOpen === index) {
187
+ this.setState({ noteMenuOpen: null });
188
+ } else {
189
+ this.setState({ noteMenuOpen: index });
190
+ }
191
+ };
192
+
193
+ onHandlePDFFileChange = (event) => {
194
+ const file = event.target.files[0];
195
+ if (!file || this.state.uploadingNoteAttachment) return;
196
+ const noteAttachments = [...this.state.noteAttachments];
197
+ const newAttachment = {
198
+ Uploading: true,
199
+ Title: file.name,
200
+ };
201
+ noteAttachments.push(newAttachment);
202
+ this.setState({
203
+ noteAttachments,
204
+ });
205
+ Apis.fileActions
206
+ .uploadMediaAsync(file, file.name)
207
+ .then((fileRes) => {
208
+ newAttachment.Source = fileRes;
209
+ newAttachment.Uploading = false;
210
+ this.setState({
211
+ noteAttachments: [...this.state.noteAttachments],
212
+ });
213
+ })
214
+ .catch((uploadErrorRes) => {
215
+ console.log(uploadErrorRes);
216
+ newAttachment.Uploading = false;
217
+ this.setState({
218
+ noteAttachments: [...this.state.noteAttachments],
219
+ });
220
+ });
221
+ event.target.value = "";
222
+ };
223
+
224
+ onRemoveAttachment = (a) => {
225
+ const index = this.state.noteAttachments.indexOf(a);
226
+ if (index > -1) {
227
+ const newAttachments = [...this.state.noteAttachments];
228
+ newAttachments.splice(index, 1);
229
+ this.setState({
230
+ noteAttachments: newAttachments,
231
+ });
232
+ }
233
+ };
234
+
235
+ onShowSelectAssignee = () => {
236
+ this.setState({
237
+ showingAssigneeSelector: true,
238
+ });
239
+ };
240
+
241
+ onCloseSelectAssignee = () => {
242
+ this.setState({
243
+ showingAssigneeSelector: false,
244
+ });
245
+ };
246
+
247
+ onSelectAssignee = (user) => {
248
+ this.setState({
249
+ selectedAssignee: user,
250
+ });
251
+ };
252
+
253
+ onConfirmAssignee = async () => {
254
+ this.setState({
255
+ confirmingAssignee: true,
256
+ });
257
+ try {
258
+ if (this.state.selectedAssignee) {
259
+ await this.onAssignUser(this.state.selectedAssignee.id);
260
+ }
261
+ this.onCloseSelectAssignee();
262
+ } catch (error) {
263
+ console.error("onConfirmAssignee", error);
264
+ }
265
+ this.setState({
266
+ confirmingAssignee: false,
267
+ });
268
+ };
269
+
270
+ // Method to handle user assignment
271
+ onAssignUser = async (userId) => {
272
+ try {
273
+ const res = await maintenanceActions.assignJob(this.state.jobId, userId);
274
+ this.getJob();
275
+ } catch (err) {
276
+ console.error("onAssignUser", err);
277
+ }
278
+ };
279
+
280
+ onConfirmAddNote = async () => {
281
+ if (!this.isReadyToSaveNote()) return;
282
+
283
+ try {
284
+ this.setState({ submittingNote: true });
285
+ const res = await (this.state.editingNote
286
+ ? maintenanceActions.editNote(
287
+ this.state.jobId,
288
+ this.state.editingNote,
289
+ this.state.noteInput,
290
+ this.state.noteAttachments.map((a) => {
291
+ return {
292
+ Title: a.Title,
293
+ Source: a.Source,
294
+ };
295
+ }),
296
+ this.state.noteImages,
297
+ )
298
+ : maintenanceActions.addNote(
299
+ this.state.jobId,
300
+ this.state.noteInput,
301
+ this.state.noteAttachments.map((a) => {
302
+ return {
303
+ Title: a.Title,
304
+ Source: a.Source,
305
+ };
306
+ }),
307
+ this.state.noteImages,
308
+ ));
309
+ this.setState(
310
+ {
311
+ job: res.data.job,
312
+ submittingNote: false,
313
+ addNoteOpen: false,
314
+ noteInput: "",
315
+ noteAttachments: [],
316
+ noteImages: [],
317
+ editingNote: null,
318
+ },
319
+ () => {
320
+ this.props.jobsLoaded([this.state.job]);
321
+ },
322
+ );
323
+ } catch (err) {
324
+ console.error("onConfirmAddNote", err);
325
+ }
326
+ };
327
+
328
+ onDeleteNote = (n) => {
329
+ if (!window.confirm(values.textAreYouSureYouWantToDeleteNote)) {
330
+ this.setState({ noteMenuOpen: null });
331
+ return;
332
+ }
333
+
334
+ maintenanceActions.deleteNote(this.state.jobId, n.Id);
335
+ const newNotes = _.filter(this.state.job.Notes, (note) => note.Id !== n.Id);
336
+ const newJob = { ...this.state.job };
337
+ newJob.Notes = newNotes;
338
+ this.setState({ job: newJob, noteMenuOpen: null });
339
+ };
340
+
341
+ onOpenEditNote = (n) => {
342
+ this.setState(
343
+ {
344
+ noteAttachments: n.Attachments || [],
345
+ noteImages: n.Images || [],
346
+ noteInput: n.Note || "",
347
+ addNoteOpen: true,
348
+ editingNote: n.Id,
349
+ noteMenuOpen: null,
350
+ },
351
+ this.checkSetImage,
352
+ );
353
+ };
354
+
355
+ markSeen = () => {
356
+ const { job } = this.state;
357
+ const { auth } = this.props;
358
+ // Must have maintenance permission and not the requester
359
+ if (
360
+ !Session.validateAccess(
361
+ auth.site,
362
+ values.permissionMaintenanceTracking,
363
+ auth,
364
+ )
365
+ )
366
+ return;
367
+ if (auth.user.Id === job.userID) return;
368
+
369
+ this.setState({ updating: true }, async () => {
370
+ try {
371
+ const update = {
372
+ id: job.id,
373
+ seen: true,
374
+ status: job.status || "Unassigned",
375
+ };
376
+
377
+ await maintenanceActions.editJob(update, auth.site);
378
+ } catch (error) {
379
+ this.setState({ updating: false });
380
+ console.log("markSeen error", error);
381
+ alert("Something went wrong with the request. Please try again.");
382
+ }
383
+ });
384
+ };
385
+
386
+ getComments = () => {
387
+ reactionActions
388
+ .getComments(this.state.jobId, values.commentKey, 0)
389
+ .then((res) => {
390
+ this.setState({ comments: res.data });
391
+ });
392
+ };
393
+
394
+ checkSetImage() {
395
+ if (this.imageInput && !_.isEmpty(this.state.noteImages)) {
396
+ this.imageInput.setValue(this.state.noteImages);
397
+ } else {
398
+ setTimeout(this.checkSetImage, 100);
399
+ }
400
+ }
401
+
402
+ onAddComment = async () => {
403
+ const { commentInput, jobId, job, comments } = this.state;
404
+ try {
405
+ this.setState({ commentInput: "" });
406
+ const res = await reactionActions.addComment(
407
+ jobId,
408
+ values.commentKey,
409
+ job.title,
410
+ job.site,
411
+ commentInput,
412
+ );
413
+ this.setState({ comments: [...comments, res.data] });
414
+ } catch (error) {
415
+ console.error("onAddComment", error);
416
+ }
417
+ };
418
+
419
+ onHandleChange = (event) => {
420
+ var stateChange = {};
421
+ stateChange[event.target.getAttribute("id")] = event.target.value;
422
+ this.setState(stateChange);
423
+ };
424
+
425
+ onTogglePriorityChanger = () => {
426
+ this.setState({ priorityChangerOpen: !this.state.priorityChangerOpen });
427
+ };
428
+
429
+ onSelectPriority = async (priority) => {
430
+ this.setState({
431
+ job: { ...this.state.job, priority },
432
+ priorityChangerOpen: false,
433
+ });
434
+
435
+ try {
436
+ const res = await maintenanceActions.editJobPriority(
437
+ this.state.job.id,
438
+ priority,
439
+ );
440
+ const { job } = res.data;
441
+ this.props.jobsLoaded([job]);
442
+ this.setState({ job });
443
+ } catch (error) {
444
+ console.error("onSelectPriority", error);
445
+ }
446
+ };
447
+
448
+ onToggleStatusChanger = () => {
449
+ this.setState({ statusChangerOpen: !this.state.statusChangerOpen });
450
+ };
451
+
452
+ onSelectStatus = async (status) => {
453
+ this.setState({
454
+ job: {
455
+ ...this.state.job,
456
+ status: status,
457
+ },
458
+ statusChangerOpen: false,
459
+ });
460
+
461
+ try {
462
+ const res = await maintenanceActions.editJobStatus(
463
+ this.state.job.id,
464
+ status,
465
+ );
466
+ const { job } = res.data;
467
+ this.props.jobsLoaded([job]);
468
+ this.setState({ job });
469
+ } catch (error) {
470
+ console.error("onSelectStatus", error);
471
+ }
472
+ };
473
+
474
+ renderPriorityChanger() {
475
+ if (!this.state.priorityChangerOpen) return null;
476
+ return (
477
+ <div className="statusChanger statusChanger-priority">
478
+ {jobPriorityOptions.map((p) => {
479
+ return (
480
+ <div
481
+ key={p.name}
482
+ className="statusLabel"
483
+ onClick={() => this.onSelectPriority(p.name)}
484
+ style={{ backgroundColor: p.color }}
485
+ >
486
+ <span className="statusLabel_text">{p.name}</span>
487
+ </div>
488
+ );
489
+ })}
490
+ </div>
491
+ );
492
+ }
493
+
494
+ renderPriorityLabel() {
495
+ const { auth } = this.props;
496
+ if (
497
+ !Session.validateAccess(
498
+ auth.site,
499
+ values.permissionMaintenanceTracking,
500
+ auth,
501
+ )
502
+ )
503
+ return null;
504
+
505
+ const { job } = this.state;
506
+ if (!job) return null;
507
+
508
+ const selectedPriority = getJobPriority(job.priority);
509
+ return (
510
+ <div
511
+ className="statusLabel marginTop-5 pointer"
512
+ onClick={this.onTogglePriorityChanger}
513
+ style={{ backgroundColor: selectedPriority.color }}
514
+ >
515
+ <span className="statusLabel_text">
516
+ {job.priority || selectedPriority.name}
517
+ </span>
518
+ {this.renderPriorityChanger()}
519
+ </div>
520
+ );
521
+ }
522
+
523
+ renderStatusLabel() {
524
+ if (!this.state.job.status) return null;
525
+ const statusType = this.getStatusType(this.state.job.status);
526
+ const { auth } = this.props;
527
+ if (
528
+ Session.validateAccess(
529
+ auth.site,
530
+ values.permissionMaintenanceTracking,
531
+ auth,
532
+ )
533
+ ) {
534
+ return (
535
+ <div
536
+ className="statusLabel pointer"
537
+ onClick={this.onToggleStatusChanger}
538
+ style={{ backgroundColor: statusType.color }}
539
+ >
540
+ <span className="statusLabel_text">{statusType.text}</span>
541
+ {this.renderStatusChanger()}
542
+ </div>
543
+ );
544
+ }
545
+ return (
546
+ <div
547
+ className="statusLabel"
548
+ style={{ backgroundColor: statusType.color }}
549
+ >
550
+ <span className="statusLabel_text">{statusType.text}</span>
551
+ </div>
552
+ );
553
+ }
554
+
555
+ renderNotesButton() {
556
+ const { auth } = this.props;
557
+ if (
558
+ !Session.validateAccess(
559
+ auth.site,
560
+ values.permissionMaintenanceTracking,
561
+ auth,
562
+ )
563
+ )
564
+ return null;
565
+ return (
566
+ <div
567
+ className="statusLabel pointer"
568
+ onClick={this.onOpenAddNote}
569
+ style={{
570
+ backgroundColor: Config.env.colourBrandingMain,
571
+ marginLeft: 8,
572
+ }}
573
+ >
574
+ <span className="statusLabel_text">Add Note</span>
575
+ </div>
576
+ );
577
+ }
578
+
579
+ renderAssignButton() {
580
+ const { auth } = this.props;
581
+ if (
582
+ !Session.validateAccess(
583
+ auth.site,
584
+ values.permissionMaintenanceTracking,
585
+ auth,
586
+ )
587
+ )
588
+ return null;
589
+ return (
590
+ <div
591
+ className="statusLabel pointer"
592
+ onClick={this.onShowSelectAssignee}
593
+ style={{
594
+ backgroundColor: Config.env.colourBrandingMain,
595
+ marginLeft: 8,
596
+ }}
597
+ >
598
+ <span className="statusLabel_text">Assign</span>
599
+ </div>
600
+ );
601
+ }
602
+
603
+ renderStatusChanger() {
604
+ if (!this.state.statusChangerOpen) return null;
605
+ const { statusTypes } = this.props;
606
+
607
+ return (
608
+ <div className="statusChanger statusChanger-maintenance">
609
+ {statusTypes.map((status) => {
610
+ return (
611
+ <div
612
+ key={status.text}
613
+ className="statusLabel"
614
+ onClick={() => this.onSelectStatus(status.text)}
615
+ style={{ backgroundColor: status.color }}
616
+ >
617
+ <span className="statusLabel_text">{status.text}</span>
618
+ </div>
619
+ );
620
+ })}
621
+ </div>
622
+ );
623
+ }
624
+
625
+ renderComment(c) {
626
+ return <Components.Comment key={c.Id} comment={c} />;
627
+ return (
628
+ <div key={c.Id} className="comment">
629
+ <p className="comment_text">{Helper.toParagraphed(c.Comment)}</p>
630
+ <div className="comment_bottom">
631
+ <Components.ProfilePic
632
+ className="comment_profilePic"
633
+ size={25}
634
+ image={c.User.profilePic}
635
+ />
636
+ <p className="comment_name">{c.User.displayName}</p>
637
+ <p className="comment_time">
638
+ {moment.utc(c.Timestamp).local().format("D MMM YYYY • h:mma")}
639
+ </p>
640
+ </div>
641
+ </div>
642
+ );
643
+ }
644
+
645
+ renderCommentSection() {
646
+ if (this.state.loadingComments) return null;
647
+
648
+ return (
649
+ <div className="padding-60 paddingLeft-20">
650
+ <div className="newTopBar paddingLeft-40">
651
+ <Components.Text type="formTitleSmall" className="marginBottom-16">
652
+ Comments
653
+ </Components.Text>
654
+ <div className="commentSection">
655
+ {this.state.comments.map((c) => this.renderComment(c))}
656
+ </div>
657
+ <div className="commentReply">
658
+ <div
659
+ className={`commentReply_button${!_.isEmpty(this.state.commentInput) ? " commentReply_button-active" : ""}`}
660
+ onClick={this.onAddComment}
661
+ >
662
+ <FontAwesome className="commentReply_icon" name="paper-plane-o" />
663
+ </div>
664
+ <Textarea
665
+ id="commentInput"
666
+ placeholder="Reply here..."
667
+ type="text"
668
+ className="commentReply_input"
669
+ value={this.state.commentInput}
670
+ onChange={(e) => this.onHandleChange(e)}
671
+ />
672
+ </div>
673
+ </div>
674
+ </div>
675
+ );
676
+ }
677
+
678
+ renderImageGrid(images, size = undefined) {
679
+ const imagesToUse = images && images.length > 0 ? images : [];
680
+ return (
681
+ <div className="imageGrid">
682
+ {imagesToUse.map((image, i) => {
683
+ return (
684
+ <a href={image} target="_blank" rel="noopener noreferrer" key={i}>
685
+ <div
686
+ className="imageGrid_image"
687
+ style={{
688
+ backgroundImage: `url('${Helper.get1400(image)}')`,
689
+ width: size,
690
+ height: size,
691
+ }}
692
+ ></div>
693
+ </a>
694
+ );
695
+ })}
696
+ </div>
697
+ );
698
+ }
699
+
700
+ renderDocumentGrid(documents) {
701
+ const documentsToUse = documents && documents.length > 0 ? documents : [];
702
+ return (
703
+ <div className="documentGrid">
704
+ {documentsToUse.map((doc, index) => (
705
+ <Components.Attachment
706
+ key={index}
707
+ uploading={doc.uploading}
708
+ source={doc.url}
709
+ title={doc.name}
710
+ />
711
+ ))}
712
+ </div>
713
+ );
714
+ }
715
+
716
+ renderImages() {
717
+ if (_.isEmpty(this.state.job.image) && _.isEmpty(this.state.job.images))
718
+ return null;
719
+
720
+ const imagesToUse = _.isEmpty(this.state.job.image)
721
+ ? this.state.job.images
722
+ : [this.state.job.image];
723
+ return (
724
+ <div className="padding-60 paddingVertical-40 bottomDivideBorder">
725
+ <Components.Text type="formTitleSmall" className="marginBottom-16">
726
+ Images
727
+ </Components.Text>
728
+ {this.renderImageGrid(imagesToUse)}
729
+ </div>
730
+ );
731
+ }
732
+
733
+ renderCustomFields() {
734
+ const { job } = this.state;
735
+ const { customFields } = job;
736
+
737
+ const labelClass = "fieldLabel";
738
+ const answerClass = "fontRegular fontSize-16 text-dark marginTop-5";
739
+
740
+ const renderAnswer = (field) => {
741
+ switch (field.type) {
742
+ case "date":
743
+ return (
744
+ <div className={answerClass}>
745
+ {field.answer
746
+ ? moment(field.answer, "YYYY-MM-DD").format("DD-MMM-YYYY")
747
+ : ""}
748
+ </div>
749
+ );
750
+ case "time":
751
+ return (
752
+ <div className={answerClass}>
753
+ {field.answer
754
+ ? moment(field.answer, "HH:mm").format("h:mm a")
755
+ : ""}
756
+ </div>
757
+ );
758
+ case "yn":
759
+ return (
760
+ <div className={answerClass}>{field.answer ? "Yes" : "No"}</div>
761
+ );
762
+ case "checkbox":
763
+ return (
764
+ <div className={answerClass}>
765
+ {field.answer && Array.isArray(field.answer)
766
+ ? field.answer.join(", ")
767
+ : ""}
768
+ </div>
769
+ );
770
+ case "image":
771
+ return this.renderImageGrid(field.answer);
772
+ case "document":
773
+ return this.renderDocumentGrid(field.answer);
774
+ default:
775
+ return <div className={answerClass}>{field.answer}</div>;
776
+ }
777
+ };
778
+
779
+ return (
780
+ <div className="padding-60 paddingVertical-40 bottomDivideBorder">
781
+ {customFields.map((field, index) => {
782
+ if (["staticTitle", "staticText"].includes(field.type)) return null;
783
+ if (
784
+ _.isNil(field.answer) ||
785
+ field.answer === "" ||
786
+ field.answer.length === 0
787
+ )
788
+ return null;
789
+ return (
790
+ <div key={index} className="marginTop-16">
791
+ <div className={labelClass}>{field.label}</div>
792
+ {renderAnswer(field)}
793
+ </div>
794
+ );
795
+ })}
796
+ </div>
797
+ );
798
+ }
799
+
800
+ renderInner() {
801
+ if (this.state.job == null) return null;
802
+ const { customFields } = this.state.job;
803
+ const hasCustomFields = customFields && customFields.length > 0;
804
+
805
+ return (
806
+ <div style={{ paddingBottom: 40 }}>
807
+ <div className="padding-60 paddingVertical-40 bottomDivideBorder relative">
808
+ <Components.Text type="formTitleLarge" className="marginBottom-8">
809
+ {this.state.job.title || values.textSingularName}
810
+ </Components.Text>
811
+ <Components.Text type="formTitleMedium" className="marginBottom-24">
812
+ {values.textEntityName} #{this.state.job.jobId}
813
+ </Components.Text>
814
+ <div className="marginTop-16">
815
+ <div className={"fieldLabel"}>Submission date</div>
816
+ <div className={"fontRegular fontSize-16 text-dark marginTop-5"}>
817
+ {moment
818
+ .utc(this.state.job.createdTime)
819
+ .local()
820
+ .format("D MMM YY")}
821
+ </div>
822
+ </div>
823
+ <div className="marginTop-16">
824
+ <div className={"fieldLabel"}>Type</div>
825
+ <div className={"fontRegular fontSize-16 text-dark marginTop-5"}>
826
+ {this.state.job.type}
827
+ </div>
828
+ </div>
829
+ <div className="marginTop-16">
830
+ <div className={"fieldLabel"}>Address</div>
831
+ <div className={"fontRegular fontSize-16 text-dark marginTop-5"}>
832
+ {this.state.job.room}
833
+ </div>
834
+ </div>
835
+ {hasCustomFields ? null : (
836
+ <div className="marginTop-16">
837
+ <div className={"fieldLabel"}>
838
+ Description {this.state.job.image ? "- (image supplied)" : ""}
839
+ </div>
840
+ <div className={"fontRegular fontSize-16 text-dark marginTop-5"}>
841
+ {this.state.job.description}
842
+ </div>
843
+ </div>
844
+ )}
845
+ </div>
846
+ <div className="padding-60 paddingVertical-40 bottomDivideBorder">
847
+ <Components.Text type="formTitleSmall" className="marginBottom-16">
848
+ Contact Details
849
+ </Components.Text>
850
+ <div className="marginTop-16">
851
+ <div className={"fieldLabel"}>Name</div>
852
+ <div className={"fontRegular fontSize-16 text-dark marginTop-5"}>
853
+ {this.state.job.userName}
854
+ </div>
855
+ </div>
856
+ <div className="marginTop-16">
857
+ <div className={"fieldLabel"}>Contact number</div>
858
+ <div className={"fontRegular fontSize-16 text-dark marginTop-5"}>
859
+ {_.isEmpty(this.state.job.phone)
860
+ ? "No phone provided"
861
+ : this.state.job.phone}
862
+ </div>
863
+ </div>
864
+ {hasCustomFields ? null : (
865
+ <div>
866
+ <div className="marginTop-16">
867
+ <div className={"fieldLabel"}>Should person be home?</div>
868
+ <div
869
+ className={"fontRegular fontSize-16 text-dark marginTop-5"}
870
+ >
871
+ {this.state.job.isHome ? "Yes" : "No"}
872
+ </div>
873
+ </div>
874
+ {this.state.job.isHome && this.state.job.homeText && (
875
+ <div className="marginTop-16">
876
+ <div className={"fieldLabel"}>When</div>
877
+ <div
878
+ className={"fontRegular fontSize-16 text-dark marginTop-5"}
879
+ >
880
+ {this.state.job.homeText}
881
+ </div>
882
+ </div>
883
+ )}
884
+ </div>
885
+ )}
886
+ </div>
887
+ {hasCustomFields ? null : this.renderImages()}
888
+ {hasCustomFields ? this.renderCustomFields() : null}
889
+ {this.renderCommentSection()}
890
+ </div>
891
+ );
892
+ }
893
+
894
+ renderHistoryEntry(e, i) {
895
+ const { job } = this.state;
896
+ const entryToUse = e || {
897
+ timestamp: job.createdTime,
898
+ status: "Unassigned",
899
+ user: {
900
+ displayName: job.userName,
901
+ id: job.userID,
902
+ profilePic: job.userProfilePic,
903
+ },
904
+ };
905
+ const statusType = this.getStatusType(entryToUse.status);
906
+ return (
907
+ <div className="ticketHistoryEntry" key={i}>
908
+ <p className="ticketHistoryEntry_timestamp">
909
+ {moment.utc(entryToUse.timestamp).local().format("D MMM YYYY h:mma")}
910
+ </p>
911
+ <div
912
+ className="statusLabel statusLabel-large statusLabel-full"
913
+ style={{ backgroundColor: statusType.color }}
914
+ >
915
+ <span className="statusLabel_text">
916
+ {e
917
+ ? `${entryToUse.user.displayName} marked as ${statusType.text}`
918
+ : `${values.textEntityName} opened`}
919
+ </span>
920
+ </div>
921
+ </div>
922
+ );
923
+ }
924
+
925
+ renderNote(note, index) {
926
+ return (
927
+ <div className="ticketHistoryEntry" key={index}>
928
+ <p className="ticketHistoryEntry_timestamp">
929
+ {moment.utc(note.Timestamp).local().format("D MMM YYYY h:mma")}
930
+ </p>
931
+ <div
932
+ className="statusLabel statusLabel-large statusLabel-full"
933
+ style={{ backgroundColor: "#6e79c5" }}
934
+ >
935
+ <span className="statusLabel_text">Staff Notes</span>
936
+ </div>
937
+ <div className="maintenanceNote">
938
+ <div className="maintenanceNote_top">
939
+ {this.props.auth &&
940
+ this.props.auth.user &&
941
+ this.props.auth.user.Id === note.User.id && (
942
+ <Components.SVGIcon
943
+ colour={Colours.COLOUR_DUSK_LIGHT}
944
+ icon="more15"
945
+ className="maintenanceNote_moreIcon"
946
+ onClick={() => this.onOpenNoteMenu(index)}
947
+ />
948
+ )}
949
+ <p className="maintenanceNote_name">{note.User.displayName}</p>
950
+ {this.state.noteMenuOpen === index && (
951
+ <Components.MoreMenu
952
+ options={[
953
+ {
954
+ key: "edit",
955
+ text: "Edit",
956
+ onPress: () => this.onOpenEditNote(note),
957
+ },
958
+ {
959
+ key: "delete",
960
+ text: "Delete",
961
+ onPress: () => this.onDeleteNote(note),
962
+ },
963
+ ]}
964
+ />
965
+ )}
966
+ </div>
967
+ <p className="maintenanceNote_text">
968
+ {Helper.toParagraphed(note.Note)}
969
+ </p>
970
+ {note.Attachments.map((a, i) => this.renderAttachment(a, i))}
971
+ {note.Images && note.Images.length > 0
972
+ ? this.renderImageGrid(note.Images, IMAGE_SIZE_NOTE)
973
+ : null}
974
+ </div>
975
+ </div>
976
+ );
977
+ }
978
+
979
+ renderAssignment() {
980
+ const { job } = this.state;
981
+ if (!job) return null;
982
+
983
+ return (
984
+ <div className="padding-32 paddingVertical-40 bottomDivideBorder relative">
985
+ <div className="newTopBar clearfix flex flex-reverse">
986
+ {this.renderAssignButton()}
987
+ <Components.Text type="formTitleSmall" className="flex-1">
988
+ Assignment
989
+ </Components.Text>
990
+ </div>
991
+ <div>
992
+ <div className="marginTop-16">
993
+ <div className={"fieldLabel"}>Assigned to</div>
994
+ <div className={"fontRegular fontSize-16 text-dark marginTop-5"}>
995
+ {job.Assignee ? (
996
+ <Components.UserListing user={job.Assignee} />
997
+ ) : (
998
+ "Unassigned"
999
+ )}
1000
+ </div>
1001
+ </div>
1002
+ </div>
1003
+ </div>
1004
+ );
1005
+ }
1006
+
1007
+ renderAssignmentEntry(e, i) {
1008
+ return (
1009
+ <div className="ticketHistoryEntry" key={i}>
1010
+ <p className="ticketHistoryEntry_timestamp">
1011
+ {moment.utc(e.timestamp).local().format("D MMM YYYY h:mma")}
1012
+ </p>
1013
+ <div
1014
+ className="statusLabel statusLabel-large statusLabel-full"
1015
+ style={{ backgroundColor: Colours.COLOUR_DUSK }}
1016
+ >
1017
+ <span className="statusLabel_text">
1018
+ {e.user.displayName} assigned the {values.textSingularName} to{" "}
1019
+ {e.assignedUser ? e.assignedUser.displayName : "Unassigned"}
1020
+ </span>
1021
+ </div>
1022
+ </div>
1023
+ );
1024
+ }
1025
+
1026
+ renderExternalSyncEntry(e, i) {
1027
+ const isSuccess = e.EntryType === "ExternalIDSet";
1028
+ const backgroundColor = isSuccess
1029
+ ? Colours.COLOUR_GREEN
1030
+ : Colours.COLOUR_RED; // Green for success, red for failure
1031
+
1032
+ return (
1033
+ <div className="ticketHistoryEntry" key={i}>
1034
+ <p className="ticketHistoryEntry_timestamp">
1035
+ {moment.utc(e.timestamp).local().format("D MMM YYYY h:mma")}
1036
+ </p>
1037
+ <div
1038
+ className="statusLabel statusLabel-large statusLabel-full"
1039
+ style={{ backgroundColor }}
1040
+ >
1041
+ <span className="statusLabel_text">
1042
+ {isSuccess
1043
+ ? `Synced to ${e.systemType || "external system"}${e.externalId ? ` (ID: ${e.externalId})` : ""}`
1044
+ : `Failed to sync to ${e.systemType || "external system"}${e.error ? `: ${e.error}` : ""}`}
1045
+ </span>
1046
+ </div>
1047
+ </div>
1048
+ );
1049
+ }
1050
+
1051
+ renderPriority() {
1052
+ const { auth } = this.props;
1053
+ if (
1054
+ !Session.validateAccess(
1055
+ auth.site,
1056
+ values.permissionMaintenanceTracking,
1057
+ auth,
1058
+ )
1059
+ )
1060
+ return null;
1061
+
1062
+ return (
1063
+ <div className="padding-32 paddingVertical-40 bottomDivideBorder relative">
1064
+ <div className="newTopBar clearfix flex flex-reverse">
1065
+ {this.renderPriorityLabel()}
1066
+ <Components.Text type="formTitleSmall" className="flex-1">
1067
+ Priority
1068
+ </Components.Text>
1069
+ </div>
1070
+ </div>
1071
+ );
1072
+ }
1073
+
1074
+ hasSyncFailureWithoutSuccess() {
1075
+ const { job } = this.state;
1076
+ if (!job || !job.history) return false;
1077
+
1078
+ const history = job.history || [];
1079
+ const hasSuccess = history.some(
1080
+ (entry) => entry.EntryType === "ExternalIDSet",
1081
+ );
1082
+ const hasFailure = history.some(
1083
+ (entry) => entry.EntryType === "ExternalIDSetFailed",
1084
+ );
1085
+
1086
+ return hasFailure && !hasSuccess;
1087
+ }
1088
+
1089
+ renderRetrySyncButton() {
1090
+ const { auth } = this.props;
1091
+ const { retryingSync, retrySyncInitiated } = this.state;
1092
+
1093
+ // Only show for users with maintenance tracking permission
1094
+ if (
1095
+ !Session.validateAccess(
1096
+ auth.site,
1097
+ values.permissionMaintenanceTracking,
1098
+ auth,
1099
+ )
1100
+ )
1101
+ return null;
1102
+
1103
+ // Only show if there's a failure without success and retry hasn't been initiated
1104
+ if (!this.hasSyncFailureWithoutSuccess() || retrySyncInitiated) return null;
1105
+
1106
+ // Show spinner while retrying
1107
+ if (retryingSync) {
1108
+ return (
1109
+ <FontAwesome
1110
+ style={{
1111
+ fontSize: 20,
1112
+ color: Colours.COLOUR_DUSK_LIGHT,
1113
+ marginLeft: 8,
1114
+ }}
1115
+ name="spinner fa-pulse fa-fw"
1116
+ />
1117
+ );
1118
+ }
1119
+
1120
+ return (
1121
+ <div
1122
+ className="statusLabel pointer"
1123
+ onClick={this.onRetrySync}
1124
+ style={{ backgroundColor: Colours.COLOUR_RED, marginLeft: 8 }}
1125
+ >
1126
+ <span className="statusLabel_text">Retry Sync</span>
1127
+ </div>
1128
+ );
1129
+ }
1130
+
1131
+ renderExternalSyncStatus() {
1132
+ const { retrySyncError, retrySyncInitiated } = this.state;
1133
+
1134
+ // Show error message if retry failed
1135
+ if (retrySyncError) {
1136
+ return (
1137
+ <Components.Text type="body">
1138
+ <FontAwesome
1139
+ className="userStatusIcon"
1140
+ name="times-circle"
1141
+ style={{ color: Colours.COLOUR_RED }}
1142
+ />{" "}
1143
+ {retrySyncError}
1144
+ </Components.Text>
1145
+ );
1146
+ }
1147
+
1148
+ // Show success message if retry was initiated
1149
+ if (retrySyncInitiated) {
1150
+ return (
1151
+ <Components.Text type="body">
1152
+ <FontAwesome
1153
+ className="userStatusIcon"
1154
+ name="check-circle"
1155
+ style={{ color: Colours.COLOUR_GREEN }}
1156
+ />{" "}
1157
+ Sync retry initiated. Check back shortly for results.
1158
+ </Components.Text>
1159
+ );
1160
+ }
1161
+
1162
+ // Show failure message with instruction
1163
+ if (this.hasSyncFailureWithoutSuccess()) {
1164
+ return (
1165
+ <Components.Text type="body">
1166
+ <FontAwesome
1167
+ className="userStatusIcon"
1168
+ name="times-circle"
1169
+ style={{ color: Colours.COLOUR_RED }}
1170
+ />{" "}
1171
+ External sync failed. Use the retry button to attempt again.
1172
+ </Components.Text>
1173
+ );
1174
+ }
1175
+
1176
+ return null;
1177
+ }
1178
+
1179
+ renderExternalSync() {
1180
+ const { externalSync, loadingExternalSync } = this.state;
1181
+
1182
+ // Check if we should show this section at all
1183
+ const hasExternalSyncData = externalSync && !loadingExternalSync;
1184
+ const hasSyncFailure = this.hasSyncFailureWithoutSuccess();
1185
+
1186
+ // Show section if we have sync data OR if there's a failure that can be retried
1187
+ if (!hasExternalSyncData && !hasSyncFailure) return null;
1188
+
1189
+ return (
1190
+ <div className="padding-32 paddingVertical-40 bottomDivideBorder relative">
1191
+ <div className="newTopBar clearfix flex flex-reverse">
1192
+ {this.renderRetrySyncButton()}
1193
+ <Components.Text type="formTitleSmall" className="flex-1">
1194
+ External Sync
1195
+ </Components.Text>
1196
+ </div>
1197
+ <div className="marginTop-16">
1198
+ {hasExternalSyncData ? (
1199
+ <>
1200
+ {externalSync.systemType && (
1201
+ <Components.Text type="body" className="marginBottom-8">
1202
+ <strong>System:</strong> {externalSync.systemType}
1203
+ </Components.Text>
1204
+ )}
1205
+ {externalSync.externalId && (
1206
+ <Components.Text type="body" className="marginBottom-8">
1207
+ <strong>External ID:</strong> {externalSync.externalId}
1208
+ </Components.Text>
1209
+ )}
1210
+ {externalSync.syncedAt && (
1211
+ <Components.Text type="body" className="marginBottom-8">
1212
+ <strong>Synced:</strong>{" "}
1213
+ {moment
1214
+ .utc(externalSync.syncedAt)
1215
+ .local()
1216
+ .format("D MMM YYYY h:mma")}
1217
+ </Components.Text>
1218
+ )}
1219
+ </>
1220
+ ) : (
1221
+ this.renderExternalSyncStatus()
1222
+ )}
1223
+ </div>
1224
+ </div>
1225
+ );
1226
+ }
1227
+
1228
+ renderOverview() {
1229
+ const { job } = this.state;
1230
+ if (!job || !job.history) return null;
1231
+
1232
+ const source = _.sortBy(
1233
+ [
1234
+ ...job.history.map((e) => {
1235
+ return { ...e, EntryType: e.EntryType || "status" };
1236
+ }),
1237
+ ...(job.Notes || []).map((e) => {
1238
+ return { ...e, timestamp: e.Timestamp, EntryType: "note" };
1239
+ }),
1240
+ ],
1241
+ "timestamp",
1242
+ );
1243
+
1244
+ return (
1245
+ <div className="padding-32 paddingVertical-40 bottomDivideBorder relative">
1246
+ <div className="newTopBar clearfix flex flex-reverse">
1247
+ {this.renderNotesButton()}
1248
+ {this.renderStatusLabel()}
1249
+ <Components.Text type="formTitleSmall" className="flex-1">
1250
+ Status History
1251
+ </Components.Text>
1252
+ </div>
1253
+ {this.renderHistoryEntry(null, -1)}
1254
+ {_.map(source, (e, i) => {
1255
+ switch (e.EntryType) {
1256
+ case "status":
1257
+ return this.renderHistoryEntry(e, i);
1258
+ case "note":
1259
+ return this.renderNote(e, i);
1260
+ case "assignment":
1261
+ return this.renderAssignmentEntry(e, i);
1262
+ case "ExternalIDSet":
1263
+ case "ExternalIDSetFailed":
1264
+ return this.renderExternalSyncEntry(e, i);
1265
+ default:
1266
+ break;
1267
+ }
1268
+ })}
1269
+ </div>
1270
+ );
1271
+ }
1272
+
1273
+ renderButtons() {
1274
+ return (
1275
+ <div>
1276
+ <Components.Button
1277
+ inline
1278
+ buttonType="tertiary"
1279
+ onClick={() => {
1280
+ window.history.back();
1281
+ }}
1282
+ isActive
1283
+ style={{ marginRight: 16 }}
1284
+ >
1285
+ Back
1286
+ </Components.Button>
1287
+ {Session.validateAccess(
1288
+ this.props.auth.site,
1289
+ values.permissionMaintenanceTracking,
1290
+ this.props.auth,
1291
+ ) &&
1292
+ !_.isEmpty(this.state.job) && (
1293
+ <Link to={`${values.routeAddRequest}/${this.state.jobId}`}>
1294
+ <Components.Button
1295
+ inline
1296
+ style={{ marginRight: 25 }}
1297
+ buttonType="outlined"
1298
+ isActive
1299
+ onClick={this.editJob}
1300
+ >
1301
+ Edit Details
1302
+ </Components.Button>
1303
+ </Link>
1304
+ )}
1305
+ </div>
1306
+ );
1307
+ }
1308
+
1309
+ renderAttachment(attachment, index, onRemove) {
1310
+ if (!attachment) return null;
1311
+
1312
+ return (
1313
+ <Components.Attachment
1314
+ key={index}
1315
+ uploading={attachment.Uploading}
1316
+ source={attachment.Source}
1317
+ title={attachment.Title}
1318
+ onRemove={onRemove ? () => onRemove(attachment) : undefined}
1319
+ />
1320
+ );
1321
+ }
1322
+
1323
+ renderAddNotePopup() {
1324
+ if (!this.state.addNoteOpen) return null;
1325
+
1326
+ if (this.state.submittingNote) {
1327
+ return (
1328
+ <Components.Popup title="Saving Note" maxWidth={600} hasPadding>
1329
+ <div className="flex flex-center-row">
1330
+ <FontAwesome className="spinner" name="spinner fa-pulse fa-fw" />
1331
+ </div>
1332
+ </Components.Popup>
1333
+ );
1334
+ }
1335
+
1336
+ return (
1337
+ <Components.Popup
1338
+ title={`${this.state.editingNote ? "Edit" : "Add"} Note`}
1339
+ onClose={this.onCloseAddNote}
1340
+ maxWidth={600}
1341
+ hasPadding
1342
+ buttons={[
1343
+ {
1344
+ type: "primary",
1345
+ onClick: this.onConfirmAddNote,
1346
+ isActive: this.isReadyToSaveNote(),
1347
+ text: "Save",
1348
+ },
1349
+ {
1350
+ type: "tertiary",
1351
+ onClick: this.onCloseAddNote,
1352
+ isActive: true,
1353
+ text: "Cancel",
1354
+ },
1355
+ ]}
1356
+ >
1357
+ <Components.GenericInput
1358
+ id="noteInput"
1359
+ type="textarea"
1360
+ componentClass="textarea"
1361
+ value={this.state.noteInput}
1362
+ placeholder="Enter note"
1363
+ onChange={(e) => this.onHandleChange(e)}
1364
+ inputStyle={{
1365
+ width: 400,
1366
+ }}
1367
+ />
1368
+ <Components.Text type="h5">Attachments</Components.Text>
1369
+ {this.state.noteAttachments.map((a, i) =>
1370
+ this.renderAttachment(a, i, this.onRemoveAttachment),
1371
+ )}
1372
+ <input
1373
+ ref={(input) => (this.attachmentInput = input)}
1374
+ id="attachmentInput"
1375
+ type="file"
1376
+ className="fileInput"
1377
+ onChange={(e) => this.onHandlePDFFileChange(e)}
1378
+ accept="application/pdf"
1379
+ />
1380
+ <div
1381
+ className="iconTextButton marginBottom-16"
1382
+ onClick={() => {
1383
+ this.attachmentInput.click();
1384
+ }}
1385
+ >
1386
+ <FontAwesome className="iconTextButton_icon" name="paperclip" />
1387
+ <p className="iconTextButton_text">Add Attachment</p>
1388
+ </div>
1389
+ <Components.ImageInput
1390
+ ref={(ref) => {
1391
+ this.imageInput = ref;
1392
+ }}
1393
+ multiple
1394
+ refreshCallback={(images) => {
1395
+ this.setState({ noteImages: images });
1396
+ }}
1397
+ />
1398
+ </Components.Popup>
1399
+ );
1400
+ }
1401
+
1402
+ renderUsers() {
1403
+ let content = null;
1404
+ if (this.state.confirmingAssignee) {
1405
+ content = (
1406
+ <div className="flex flex-center-row">
1407
+ <FontAwesome className="spinner" name="spinner fa-pulse fa-fw" />
1408
+ </div>
1409
+ );
1410
+ } else if (this.state.selectedAssignee) {
1411
+ content = (
1412
+ <div>
1413
+ <Components.UserListing
1414
+ key={this.state.selectedAssignee.id}
1415
+ user={this.state.selectedAssignee}
1416
+ rightContent={
1417
+ <Components.SVGIcon
1418
+ className="removeIcon"
1419
+ icon="close"
1420
+ onClick={() => {
1421
+ this.onSelectAssignee();
1422
+ }}
1423
+ colour={Colours.COLOUR_DUSK}
1424
+ />
1425
+ }
1426
+ />
1427
+ </div>
1428
+ );
1429
+ } else {
1430
+ content = (
1431
+ <div>
1432
+ <Components.GenericInput
1433
+ id="userSearch"
1434
+ type="text"
1435
+ // label="Search"
1436
+ placeholder="Search name"
1437
+ value={this.state.userSearch}
1438
+ onChange={(e) => this.onHandleChange(e)}
1439
+ alwaysShowLabel
1440
+ />
1441
+ {_.sortBy(this.state.assignees, (u) => u.displayName.toUpperCase())
1442
+ .filter((u) => {
1443
+ if (_.isEmpty(this.state.userSearch)) return true;
1444
+ return (
1445
+ u.displayName
1446
+ .toUpperCase()
1447
+ .indexOf(this.state.userSearch.toUpperCase()) > -1
1448
+ );
1449
+ })
1450
+ .map((user) => {
1451
+ return (
1452
+ <Components.UserListing
1453
+ key={user.id}
1454
+ user={user}
1455
+ onClick={() => {
1456
+ this.onSelectAssignee(user);
1457
+ }}
1458
+ />
1459
+ );
1460
+ })}
1461
+ </div>
1462
+ );
1463
+ }
1464
+ return (
1465
+ <div className="genericInputContainer">
1466
+ <Components.Text type="formLabel">Select User</Components.Text>
1467
+ {content}
1468
+ </div>
1469
+ );
1470
+ }
1471
+
1472
+ renderUserSelectionPopup() {
1473
+ if (!this.state.showingAssigneeSelector) return null;
1474
+ return (
1475
+ <Components.Popup
1476
+ title="Assign Job"
1477
+ onClose={this.onCloseSelectAssignee}
1478
+ maxWidth={600}
1479
+ hasPadding
1480
+ buttons={[
1481
+ {
1482
+ type: "primary",
1483
+ onClick: this.onConfirmAssignee,
1484
+ isActive: !!this.state.selectedAssignee,
1485
+ text: "Confirm",
1486
+ },
1487
+ {
1488
+ type: "tertiary",
1489
+ onClick: this.onCloseSelectAssignee,
1490
+ isActive: true,
1491
+ text: "Cancel",
1492
+ },
1493
+ ]}
1494
+ >
1495
+ {this.renderUsers()}
1496
+ </Components.Popup>
1497
+ );
1498
+ }
1499
+
1500
+ render() {
1501
+ return (
1502
+ <Components.OverlayPage>
1503
+ {this.renderAddNotePopup()}
1504
+ {this.renderUserSelectionPopup()}
1505
+ <Components.OverlayPageContents>
1506
+ <Components.OverlayPageSection className="pageSectionWrapper--fixedPopupSize">
1507
+ {this.renderInner()}
1508
+ </Components.OverlayPageSection>
1509
+ <Components.OverlayPageSection className="pageSectionWrapper--newPopupSide pageSectionWrapper--newPopupSide-fixedWidth">
1510
+ {this.renderAssignment()}
1511
+ {this.renderPriority()}
1512
+ {this.renderExternalSync()}
1513
+ {this.renderOverview()}
1514
+ </Components.OverlayPageSection>
1515
+ </Components.OverlayPageContents>
1516
+ <Components.OverlayPageBottomButtons>
1517
+ {this.renderButtons()}
1518
+ </Components.OverlayPageBottomButtons>
1519
+ </Components.OverlayPage>
1520
+ );
1521
+ }
1264
1522
  }
1265
1523
 
1266
1524
  const mapStateToProps = (state) => {
1267
- const { auth } = state;
1268
- return { auth, statusTypes: state[values.reducerKey].jobstatuses };
1525
+ const { auth } = state;
1526
+ return { auth, statusTypes: state[values.reducerKey].jobstatuses };
1269
1527
  };
1270
1528
 
1271
- export default connect(mapStateToProps, { jobsLoaded, jobStatusesUpdate })(withRouter(Job));
1529
+ export default connect(mapStateToProps, { jobsLoaded, jobStatusesUpdate })(
1530
+ withRouter(Job),
1531
+ );