@plusscommunities/pluss-maintenance-web-forms 1.1.27 → 1.1.28-auth.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,1097 +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
- };
37
- }
38
-
39
- UNSAFE_componentWillReceiveProps(nextProps) {
40
- Session.checkLoggedIn(this, this.props.auth);
41
- }
42
-
43
- componentDidMount() {
44
- this.props.jobStatusesUpdate(this.props.auth.site);
45
- if (this.state.jobId) {
46
- this.getJob();
47
- this.getComments();
48
- this.getAssignees();
49
- }
50
- }
51
-
52
- getJob = async () => {
53
- try {
54
- const res = await maintenanceActions.getJob(this.props.auth.site, this.state.jobId);
55
- this.setState({ updating: false });
56
- res.data.location = res.data.site;
57
- this.setJob(res.data);
58
- } catch (error) {
59
- console.error('getJob', error);
60
- }
61
- };
62
-
63
- getAssignees = async () => {
64
- try {
65
- const res = await maintenanceActions.getAssignees(this.props.auth.site);
66
- this.setState({ assignees: res.data.Users });
67
- } catch (error) {
68
- console.error('getAssignees', error);
69
- }
70
- };
71
-
72
- getStatusType = (status) => {
73
- const { statusTypes } = this.props;
74
- let statusType = statusTypes.find((s) => s.text === status);
75
- if (!statusType) {
76
- const defaultStatus = statusTypes.find((s) => s.category === STATUS_NOT_ACTIONED);
77
- statusType = { ...defaultStatus, text: status };
78
- }
79
- return statusType;
80
- };
81
-
82
- setJob = (job) => {
83
- if (_.isEmpty(job.lastActivity)) {
84
- job.lastActivity = '-- --';
85
- job.noActivity = true;
86
- }
87
- if (_.isEmpty(job.status)) {
88
- job.status = 'Unassigned';
89
- job.notStatus = true;
90
- }
91
- if (_.isEmpty(job.audience)) {
92
- job.audience = [{ displayName: 'Unassigned', isEmpty: true }];
93
- }
94
- let needToMarkSeen = false;
95
- if (!job.seen) {
96
- job.seen = true;
97
- needToMarkSeen = true;
98
- }
99
- this.setState({ job }, () => {
100
- if (needToMarkSeen) this.markSeen();
101
- });
102
- this.props.jobsLoaded([job]);
103
- };
104
-
105
- editJob = () => {};
106
-
107
- isReadyToSaveNote = () => {
108
- const { noteAttachments, noteInput, noteImages } = this.state;
109
- if (_.some(noteAttachments, (n) => n.Uploading)) return false;
110
- return !_.isEmpty(noteInput) || !_.isEmpty(noteAttachments) || !_.isEmpty(noteImages);
111
- };
112
-
113
- onOpenAddNote = () => {
114
- this.setState({ addNoteOpen: true, editingNote: null });
115
- };
116
-
117
- onCloseAddNote = () => {
118
- const newState = { addNoteOpen: false, editingNote: null };
119
- if (!!this.state.editingNote) {
120
- newState.noteInput = '';
121
- newState.noteAttachments = [];
122
- newState.noteImages = [];
123
- }
124
- this.setState(newState);
125
- };
126
-
127
- onOpenNoteMenu = (index) => {
128
- if (this.state.noteMenuOpen === index) {
129
- this.setState({ noteMenuOpen: null });
130
- } else {
131
- this.setState({ noteMenuOpen: index });
132
- }
133
- };
134
-
135
- onHandlePDFFileChange = (event) => {
136
- const file = event.target.files[0];
137
- if (!file || this.state.uploadingNoteAttachment) return;
138
- const noteAttachments = [...this.state.noteAttachments];
139
- const newAttachment = {
140
- Uploading: true,
141
- Title: file.name,
142
- };
143
- noteAttachments.push(newAttachment);
144
- this.setState({
145
- noteAttachments,
146
- });
147
- Apis.fileActions
148
- .uploadMediaAsync(file, file.name)
149
- .then((fileRes) => {
150
- newAttachment.Source = fileRes;
151
- newAttachment.Uploading = false;
152
- this.setState({
153
- noteAttachments: [...this.state.noteAttachments],
154
- });
155
- })
156
- .catch((uploadErrorRes) => {
157
- console.log(uploadErrorRes);
158
- newAttachment.Uploading = false;
159
- this.setState({
160
- noteAttachments: [...this.state.noteAttachments],
161
- });
162
- });
163
- event.target.value = '';
164
- };
165
-
166
- onRemoveAttachment = (a) => {
167
- const index = this.state.noteAttachments.indexOf(a);
168
- if (index > -1) {
169
- const newAttachments = [...this.state.noteAttachments];
170
- newAttachments.splice(index, 1);
171
- this.setState({
172
- noteAttachments: newAttachments,
173
- });
174
- }
175
- };
176
-
177
- onShowSelectAssignee = () => {
178
- this.setState({
179
- showingAssigneeSelector: true,
180
- });
181
- };
182
-
183
- onCloseSelectAssignee = () => {
184
- this.setState({
185
- showingAssigneeSelector: false,
186
- });
187
- };
188
-
189
- onSelectAssignee = (user) => {
190
- this.setState({
191
- selectedAssignee: user,
192
- });
193
- };
194
-
195
- onConfirmAssignee = async () => {
196
- this.setState({
197
- confirmingAssignee: true,
198
- });
199
- try {
200
- if (this.state.selectedAssignee) {
201
- await this.onAssignUser(this.state.selectedAssignee.id);
202
- }
203
- this.onCloseSelectAssignee();
204
- } catch (error) {
205
- console.error('onConfirmAssignee', error);
206
- }
207
- this.setState({
208
- confirmingAssignee: false,
209
- });
210
- };
211
-
212
- // Method to handle user assignment
213
- onAssignUser = async (userId) => {
214
- try {
215
- const res = await maintenanceActions.assignJob(this.state.jobId, userId);
216
- this.getJob();
217
- } catch (err) {
218
- console.error('onAssignUser', err);
219
- }
220
- };
221
-
222
- onConfirmAddNote = async () => {
223
- if (!this.isReadyToSaveNote()) return;
224
-
225
- try {
226
- this.setState({ submittingNote: true });
227
- const res = await (this.state.editingNote
228
- ? maintenanceActions.editNote(
229
- this.state.jobId,
230
- this.state.editingNote,
231
- this.state.noteInput,
232
- this.state.noteAttachments.map((a) => {
233
- return {
234
- Title: a.Title,
235
- Source: a.Source,
236
- };
237
- }),
238
- this.state.noteImages,
239
- )
240
- : maintenanceActions.addNote(
241
- this.state.jobId,
242
- this.state.noteInput,
243
- this.state.noteAttachments.map((a) => {
244
- return {
245
- Title: a.Title,
246
- Source: a.Source,
247
- };
248
- }),
249
- this.state.noteImages,
250
- ));
251
- this.setState(
252
- {
253
- job: res.data.job,
254
- submittingNote: false,
255
- addNoteOpen: false,
256
- noteInput: '',
257
- noteAttachments: [],
258
- noteImages: [],
259
- editingNote: null,
260
- },
261
- () => {
262
- this.props.jobsLoaded([this.state.job]);
263
- },
264
- );
265
- } catch (err) {
266
- console.error('onConfirmAddNote', err);
267
- }
268
- };
269
-
270
- onDeleteNote = (n) => {
271
- if (!window.confirm(values.textAreYouSureYouWantToDeleteNote)) {
272
- this.setState({ noteMenuOpen: null });
273
- return;
274
- }
275
-
276
- maintenanceActions.deleteNote(this.state.jobId, n.Id);
277
- const newNotes = _.filter(this.state.job.Notes, (note) => note.Id !== n.Id);
278
- const newJob = { ...this.state.job };
279
- newJob.Notes = newNotes;
280
- this.setState({ job: newJob, noteMenuOpen: null });
281
- };
282
-
283
- onOpenEditNote = (n) => {
284
- this.setState(
285
- {
286
- noteAttachments: n.Attachments || [],
287
- noteImages: n.Images || [],
288
- noteInput: n.Note || '',
289
- addNoteOpen: true,
290
- editingNote: n.Id,
291
- noteMenuOpen: null,
292
- },
293
- this.checkSetImage,
294
- );
295
- };
296
-
297
- markSeen = () => {
298
- const { job } = this.state;
299
- const { auth } = this.props;
300
- // Must have maintenance permission and not the requester
301
- if (!Session.validateAccess(auth.site, values.permissionMaintenanceTracking, auth)) return;
302
- if (auth.user.Id === job.userID) return;
303
-
304
- this.setState({ updating: true }, async () => {
305
- try {
306
- const update = {
307
- id: job.id,
308
- seen: true,
309
- status: job.status || 'Unassigned',
310
- };
311
-
312
- await maintenanceActions.editJob(update, auth.site);
313
- } catch (error) {
314
- this.setState({ updating: false });
315
- console.log('markSeen error', error);
316
- alert('Something went wrong with the request. Please try again.');
317
- }
318
- });
319
- };
320
-
321
- getComments = () => {
322
- reactionActions.getComments(this.state.jobId, values.commentKey, 0).then((res) => {
323
- this.setState({ comments: res.data });
324
- });
325
- };
326
-
327
- checkSetImage() {
328
- if (this.imageInput && !_.isEmpty(this.state.noteImages)) {
329
- this.imageInput.getWrappedInstance().setValue(this.state.noteImages);
330
- } else {
331
- setTimeout(this.checkSetImage, 100);
332
- }
333
- }
334
-
335
- onAddComment = async () => {
336
- const { commentInput, jobId, job, comments } = this.state;
337
- try {
338
- this.setState({ commentInput: '' });
339
- const res = await reactionActions.addComment(jobId, values.commentKey, job.title, job.site, commentInput);
340
- this.setState({ comments: [...comments, res.data] });
341
- } catch (error) {
342
- console.error('onAddComment', error);
343
- }
344
- };
345
-
346
- onHandleChange = (event) => {
347
- var stateChange = {};
348
- stateChange[event.target.getAttribute('id')] = event.target.value;
349
- this.setState(stateChange);
350
- };
351
-
352
- onTogglePriorityChanger = () => {
353
- this.setState({ priorityChangerOpen: !this.state.priorityChangerOpen });
354
- };
355
-
356
- onSelectPriority = async (priority) => {
357
- this.setState({
358
- job: { ...this.state.job, priority },
359
- priorityChangerOpen: false,
360
- });
361
-
362
- try {
363
- const res = await maintenanceActions.editJobPriority(this.state.job.id, priority);
364
- const { job } = res.data;
365
- this.props.jobsLoaded([job]);
366
- this.setState({ job });
367
- } catch (error) {
368
- console.error('onSelectPriority', error);
369
- }
370
- };
371
-
372
- onToggleStatusChanger = () => {
373
- this.setState({ statusChangerOpen: !this.state.statusChangerOpen });
374
- };
375
-
376
- onSelectStatus = async (status) => {
377
- this.setState({
378
- job: {
379
- ...this.state.job,
380
- status: status,
381
- },
382
- statusChangerOpen: false,
383
- });
384
-
385
- try {
386
- const res = await maintenanceActions.editJobStatus(this.state.job.id, status);
387
- const { job } = res.data;
388
- this.props.jobsLoaded([job]);
389
- this.setState({ job });
390
- } catch (error) {
391
- console.error('onSelectStatus', error);
392
- }
393
- };
394
-
395
- renderPriorityChanger() {
396
- if (!this.state.priorityChangerOpen) return null;
397
- return (
398
- <div className="statusChanger statusChanger-priority">
399
- {jobPriorityOptions.map((p) => {
400
- return (
401
- <div key={p.name} className="statusLabel" onClick={() => this.onSelectPriority(p.name)} style={{ backgroundColor: p.color }}>
402
- <span className="statusLabel_text">{p.name}</span>
403
- </div>
404
- );
405
- })}
406
- </div>
407
- );
408
- }
409
-
410
- renderPriorityLabel() {
411
- const { auth } = this.props;
412
- if (!Session.validateAccess(auth.site, values.permissionMaintenanceTracking, auth)) return null;
413
-
414
- const { job } = this.state;
415
- if (!job) return null;
416
-
417
- const selectedPriority = getJobPriority(job.priority);
418
- return (
419
- <div
420
- className="statusLabel marginTop-5 pointer"
421
- onClick={this.onTogglePriorityChanger}
422
- style={{ backgroundColor: selectedPriority.color }}
423
- >
424
- <span className="statusLabel_text">{job.priority || selectedPriority.name}</span>
425
- {this.renderPriorityChanger()}
426
- </div>
427
- );
428
- }
429
-
430
- renderStatusLabel() {
431
- if (!this.state.job.status) return null;
432
- const statusType = this.getStatusType(this.state.job.status);
433
- const { auth } = this.props;
434
- if (Session.validateAccess(auth.site, values.permissionMaintenanceTracking, auth)) {
435
- return (
436
- <div className="statusLabel pointer" onClick={this.onToggleStatusChanger} style={{ backgroundColor: statusType.color }}>
437
- <span className="statusLabel_text">{statusType.text}</span>
438
- {this.renderStatusChanger()}
439
- </div>
440
- );
441
- }
442
- return (
443
- <div className="statusLabel" style={{ backgroundColor: statusType.color }}>
444
- <span className="statusLabel_text">{statusType.text}</span>
445
- </div>
446
- );
447
- }
448
-
449
- renderNotesButton() {
450
- const { auth } = this.props;
451
- if (!Session.validateAccess(auth.site, values.permissionMaintenanceTracking, auth)) return null;
452
- return (
453
- <div
454
- className="statusLabel pointer"
455
- onClick={this.onOpenAddNote}
456
- style={{ backgroundColor: Config.env.colourBrandingMain, marginLeft: 8 }}
457
- >
458
- <span className="statusLabel_text">Add Note</span>
459
- </div>
460
- );
461
- }
462
-
463
- renderAssignButton() {
464
- const { auth } = this.props;
465
- if (!Session.validateAccess(auth.site, values.permissionMaintenanceTracking, auth)) return null;
466
- return (
467
- <div
468
- className="statusLabel pointer"
469
- onClick={this.onShowSelectAssignee}
470
- style={{ backgroundColor: Config.env.colourBrandingMain, marginLeft: 8 }}
471
- >
472
- <span className="statusLabel_text">Assign</span>
473
- </div>
474
- );
475
- }
476
-
477
- renderStatusChanger() {
478
- if (!this.state.statusChangerOpen) return null;
479
- const { statusTypes } = this.props;
480
-
481
- return (
482
- <div className="statusChanger statusChanger-maintenance">
483
- {statusTypes.map((status) => {
484
- return (
485
- <div
486
- key={status.text}
487
- className="statusLabel"
488
- onClick={() => this.onSelectStatus(status.text)}
489
- style={{ backgroundColor: status.color }}
490
- >
491
- <span className="statusLabel_text">{status.text}</span>
492
- </div>
493
- );
494
- })}
495
- </div>
496
- );
497
- }
498
-
499
- renderComment(c) {
500
- return <Components.Comment key={c.Id} comment={c} />;
501
- return (
502
- <div key={c.Id} className="comment">
503
- <p className="comment_text">{Helper.toParagraphed(c.Comment)}</p>
504
- <div className="comment_bottom">
505
- <Components.ProfilePic className="comment_profilePic" size={25} image={c.User.profilePic} />
506
- <p className="comment_name">{c.User.displayName}</p>
507
- <p className="comment_time">{moment.utc(c.Timestamp).local().format('D MMM YYYY • h:mma')}</p>
508
- </div>
509
- </div>
510
- );
511
- }
512
-
513
- renderCommentSection() {
514
- if (this.state.loadingComments) return null;
515
-
516
- return (
517
- <div className="padding-60 paddingLeft-20">
518
- <div className="newTopBar paddingLeft-40">
519
- <Components.Text type="formTitleSmall" className="marginBottom-16">
520
- Comments
521
- </Components.Text>
522
- <div className="commentSection">{this.state.comments.map((c) => this.renderComment(c))}</div>
523
- <div className="commentReply">
524
- <div
525
- className={`commentReply_button${!_.isEmpty(this.state.commentInput) ? ' commentReply_button-active' : ''}`}
526
- onClick={this.onAddComment}
527
- >
528
- <FontAwesome className="commentReply_icon" name="paper-plane-o" />
529
- </div>
530
- <Textarea
531
- id="commentInput"
532
- placeholder="Reply here..."
533
- type="text"
534
- className="commentReply_input"
535
- value={this.state.commentInput}
536
- onChange={(e) => this.onHandleChange(e)}
537
- />
538
- </div>
539
- </div>
540
- </div>
541
- );
542
- }
543
-
544
- renderImageGrid(images, size = undefined) {
545
- const imagesToUse = images && images.length > 0 ? images : [];
546
- return (
547
- <div className="imageGrid">
548
- {imagesToUse.map((image, i) => {
549
- return (
550
- <a href={image} target="_blank" rel="noopener noreferrer" key={i}>
551
- <div
552
- className="imageGrid_image"
553
- style={{ backgroundImage: `url('${Helper.get1400(image)}')`, width: size, height: size }}
554
- ></div>
555
- </a>
556
- );
557
- })}
558
- </div>
559
- );
560
- }
561
-
562
- renderDocumentGrid(documents) {
563
- const documentsToUse = documents && documents.length > 0 ? documents : [];
564
- return (
565
- <div className="documentGrid">
566
- {documentsToUse.map((doc, index) => (
567
- <Components.Attachment key={index} uploading={doc.uploading} source={doc.url} title={doc.name} />
568
- ))}
569
- </div>
570
- );
571
- }
572
-
573
- renderImages() {
574
- if (_.isEmpty(this.state.job.image) && _.isEmpty(this.state.job.images)) return null;
575
-
576
- const imagesToUse = _.isEmpty(this.state.job.image) ? this.state.job.images : [this.state.job.image];
577
- return (
578
- <div className="padding-60 paddingVertical-40 bottomDivideBorder">
579
- <Components.Text type="formTitleSmall" className="marginBottom-16">
580
- Images
581
- </Components.Text>
582
- {this.renderImageGrid(imagesToUse)}
583
- </div>
584
- );
585
- }
586
-
587
- renderCustomFields() {
588
- const { job } = this.state;
589
- const { customFields } = job;
590
-
591
- const labelClass = 'fieldLabel';
592
- const answerClass = 'fontRegular fontSize-16 text-dark marginTop-5';
593
-
594
- const renderAnswer = (field) => {
595
- switch (field.type) {
596
- case 'date':
597
- return <div className={answerClass}>{field.answer ? moment(field.answer, 'YYYY-MM-DD').format('DD-MMM-YYYY') : ''}</div>;
598
- case 'time':
599
- return <div className={answerClass}>{field.answer ? moment(field.answer, 'HH:mm').format('h:mm a') : ''}</div>;
600
- case 'yn':
601
- return <div className={answerClass}>{field.answer ? 'Yes' : 'No'}</div>;
602
- case 'checkbox':
603
- return <div className={answerClass}>{field.answer && Array.isArray(field.answer) ? field.answer.join(', ') : ''}</div>;
604
- case 'image':
605
- return this.renderImageGrid(field.answer);
606
- case 'document':
607
- return this.renderDocumentGrid(field.answer);
608
- default:
609
- return <div className={answerClass}>{field.answer}</div>;
610
- }
611
- };
612
-
613
- return (
614
- <div className="padding-60 paddingVertical-40 bottomDivideBorder">
615
- {customFields.map((field, index) => {
616
- if (['staticTitle', 'staticText'].includes(field.type)) return null;
617
- if (_.isNil(field.answer) || field.answer === '' || field.answer.length === 0) return null;
618
- return (
619
- <div key={index} className="marginTop-16">
620
- <div className={labelClass}>{field.label}</div>
621
- {renderAnswer(field)}
622
- </div>
623
- );
624
- })}
625
- </div>
626
- );
627
- }
628
-
629
- renderInner() {
630
- if (this.state.job == null) return null;
631
- const { customFields } = this.state.job;
632
- const hasCustomFields = customFields && customFields.length > 0;
633
-
634
- return (
635
- <div style={{ paddingBottom: 40 }}>
636
- <div className="padding-60 paddingVertical-40 bottomDivideBorder relative">
637
- <Components.Text type="formTitleLarge" className="marginBottom-8">
638
- {this.state.job.title || values.textSingularName}
639
- </Components.Text>
640
- <Components.Text type="formTitleMedium" className="marginBottom-24">
641
- {values.textEntityName} #{this.state.job.jobId}
642
- </Components.Text>
643
- <div className="marginTop-16">
644
- <div className={'fieldLabel'}>Submission date</div>
645
- <div className={'fontRegular fontSize-16 text-dark marginTop-5'}>
646
- {moment.utc(this.state.job.createdTime).local().format('D MMM YY')}
647
- </div>
648
- </div>
649
- <div className="marginTop-16">
650
- <div className={'fieldLabel'}>Type</div>
651
- <div className={'fontRegular fontSize-16 text-dark marginTop-5'}>{this.state.job.type}</div>
652
- </div>
653
- <div className="marginTop-16">
654
- <div className={'fieldLabel'}>Address</div>
655
- <div className={'fontRegular fontSize-16 text-dark marginTop-5'}>{this.state.job.room}</div>
656
- </div>
657
- {hasCustomFields ? null : (
658
- <div className="marginTop-16">
659
- <div className={'fieldLabel'}>Description {this.state.job.image ? '- (image supplied)' : ''}</div>
660
- <div className={'fontRegular fontSize-16 text-dark marginTop-5'}>{this.state.job.description}</div>
661
- </div>
662
- )}
663
- </div>
664
- <div className="padding-60 paddingVertical-40 bottomDivideBorder">
665
- <Components.Text type="formTitleSmall" className="marginBottom-16">
666
- Contact Details
667
- </Components.Text>
668
- <div className="marginTop-16">
669
- <div className={'fieldLabel'}>Name</div>
670
- <div className={'fontRegular fontSize-16 text-dark marginTop-5'}>{this.state.job.userName}</div>
671
- </div>
672
- <div className="marginTop-16">
673
- <div className={'fieldLabel'}>Contact number</div>
674
- <div className={'fontRegular fontSize-16 text-dark marginTop-5'}>
675
- {_.isEmpty(this.state.job.phone) ? 'No phone provided' : this.state.job.phone}
676
- </div>
677
- </div>
678
- {hasCustomFields ? null : (
679
- <div>
680
- <div className="marginTop-16">
681
- <div className={'fieldLabel'}>Should person be home?</div>
682
- <div className={'fontRegular fontSize-16 text-dark marginTop-5'}>{this.state.job.isHome ? 'Yes' : 'No'}</div>
683
- </div>
684
- {this.state.job.isHome && this.state.job.homeText && (
685
- <div className="marginTop-16">
686
- <div className={'fieldLabel'}>When</div>
687
- <div className={'fontRegular fontSize-16 text-dark marginTop-5'}>{this.state.job.homeText}</div>
688
- </div>
689
- )}
690
- </div>
691
- )}
692
- </div>
693
- {hasCustomFields ? null : this.renderImages()}
694
- {hasCustomFields ? this.renderCustomFields() : null}
695
- {this.renderCommentSection()}
696
- </div>
697
- );
698
- }
699
-
700
- renderHistoryEntry(e, i) {
701
- const { job } = this.state;
702
- const entryToUse = e || {
703
- timestamp: job.createdTime,
704
- status: 'Unassigned',
705
- user: {
706
- displayName: job.userName,
707
- id: job.userID,
708
- profilePic: job.userProfilePic,
709
- },
710
- };
711
- const statusType = this.getStatusType(entryToUse.status);
712
- return (
713
- <div className="ticketHistoryEntry" key={i}>
714
- <p className="ticketHistoryEntry_timestamp">{moment.utc(entryToUse.timestamp).local().format('D MMM YYYY h:mma')}</p>
715
- <div className="statusLabel statusLabel-large statusLabel-full" style={{ backgroundColor: statusType.color }}>
716
- <span className="statusLabel_text">
717
- {e ? `${entryToUse.user.displayName} marked as ${statusType.text}` : `${values.textEntityName} opened`}
718
- </span>
719
- </div>
720
- </div>
721
- );
722
- }
723
-
724
- renderNote(note, index) {
725
- return (
726
- <div className="ticketHistoryEntry" key={index}>
727
- <p className="ticketHistoryEntry_timestamp">{moment.utc(note.Timestamp).local().format('D MMM YYYY h:mma')}</p>
728
- <div className="statusLabel statusLabel-large statusLabel-full" style={{ backgroundColor: '#6e79c5' }}>
729
- <span className="statusLabel_text">Staff Notes</span>
730
- </div>
731
- <div className="maintenanceNote">
732
- <div className="maintenanceNote_top">
733
- {this.props.auth && this.props.auth.user && this.props.auth.user.Id === note.User.id && (
734
- <Components.SVGIcon
735
- colour={Colours.COLOUR_DUSK_LIGHT}
736
- icon="more15"
737
- className="maintenanceNote_moreIcon"
738
- onClick={() => this.onOpenNoteMenu(index)}
739
- />
740
- )}
741
- <p className="maintenanceNote_name">{note.User.displayName}</p>
742
- {this.state.noteMenuOpen === index && (
743
- <Components.MoreMenu
744
- options={[
745
- {
746
- key: 'edit',
747
- text: 'Edit',
748
- onPress: () => this.onOpenEditNote(note),
749
- },
750
- {
751
- key: 'delete',
752
- text: 'Delete',
753
- onPress: () => this.onDeleteNote(note),
754
- },
755
- ]}
756
- />
757
- )}
758
- </div>
759
- <p className="maintenanceNote_text">{Helper.toParagraphed(note.Note)}</p>
760
- {note.Attachments.map((a, i) => this.renderAttachment(a, i))}
761
- {note.Images && note.Images.length > 0 ? this.renderImageGrid(note.Images, IMAGE_SIZE_NOTE) : null}
762
- </div>
763
- </div>
764
- );
765
- }
766
-
767
- renderAssignment() {
768
- const { job } = this.state;
769
- if (!job) return null;
770
-
771
- return (
772
- <div className="padding-32 paddingVertical-40 bottomDivideBorder relative">
773
- <div className="newTopBar clearfix flex flex-reverse">
774
- {this.renderAssignButton()}
775
- <Components.Text type="formTitleSmall" className="flex-1">
776
- Assignment
777
- </Components.Text>
778
- </div>
779
- <div>
780
- <div className="marginTop-16">
781
- <div className={'fieldLabel'}>Assigned to</div>
782
- <div className={'fontRegular fontSize-16 text-dark marginTop-5'}>
783
- {job.Assignee ? <Components.UserListing user={job.Assignee} /> : 'Unassigned'}
784
- </div>
785
- </div>
786
- </div>
787
- </div>
788
- );
789
- }
790
-
791
- renderAssignmentEntry(e, i) {
792
- return (
793
- <div className="ticketHistoryEntry" key={i}>
794
- <p className="ticketHistoryEntry_timestamp">{moment.utc(e.timestamp).local().format('D MMM YYYY h:mma')}</p>
795
- <div className="statusLabel statusLabel-large statusLabel-full" style={{ backgroundColor: Colours.COLOUR_DUSK }}>
796
- <span className="statusLabel_text">
797
- {e.user.displayName} assigned the {values.textSingularName} to {e.assignedUser ? e.assignedUser.displayName : 'Unassigned'}
798
- </span>
799
- </div>
800
- </div>
801
- );
802
- }
803
-
804
- renderPriority() {
805
- const { auth } = this.props;
806
- if (!Session.validateAccess(auth.site, values.permissionMaintenanceTracking, auth)) return null;
807
-
808
- return (
809
- <div className="padding-32 paddingVertical-40 bottomDivideBorder relative">
810
- <div className="newTopBar clearfix flex flex-reverse">
811
- {this.renderPriorityLabel()}
812
- <Components.Text type="formTitleSmall" className="flex-1">
813
- Priority
814
- </Components.Text>
815
- </div>
816
- </div>
817
- );
818
- }
819
-
820
- renderOverview() {
821
- const { job } = this.state;
822
- if (!job || !job.history) return null;
823
-
824
- const source = _.sortBy(
825
- [
826
- ...job.history.map((e) => {
827
- return { ...e, EntryType: e.EntryType || 'status' };
828
- }),
829
- ...(job.Notes || []).map((e) => {
830
- return { ...e, timestamp: e.Timestamp, EntryType: 'note' };
831
- }),
832
- ],
833
- 'timestamp',
834
- );
835
-
836
- return (
837
- <div className="padding-32 paddingVertical-40 bottomDivideBorder relative">
838
- <div className="newTopBar clearfix flex flex-reverse">
839
- {this.renderNotesButton()}
840
- {this.renderStatusLabel()}
841
- <Components.Text type="formTitleSmall" className="flex-1">
842
- Status History
843
- </Components.Text>
844
- </div>
845
- {this.renderHistoryEntry(null, -1)}
846
- {_.map(source, (e, i) => {
847
- switch (e.EntryType) {
848
- case 'status':
849
- return this.renderHistoryEntry(e, i);
850
- case 'note':
851
- return this.renderNote(e, i);
852
- case 'assignment':
853
- return this.renderAssignmentEntry(e, i);
854
- default:
855
- break;
856
- }
857
- })}
858
- </div>
859
- );
860
- }
861
-
862
- renderButtons() {
863
- return (
864
- <div>
865
- <Components.Button
866
- inline
867
- buttonType="tertiary"
868
- onClick={() => {
869
- window.history.back();
870
- }}
871
- isActive
872
- style={{ marginRight: 16 }}
873
- >
874
- Back
875
- </Components.Button>
876
- {Session.validateAccess(this.props.auth.site, values.permissionMaintenanceTracking, this.props.auth) &&
877
- !_.isEmpty(this.state.job) && (
878
- <Link to={`${values.routeAddRequest}/${this.state.jobId}`}>
879
- <Components.Button inline style={{ marginRight: 25 }} buttonType="outlined" isActive onClick={this.editJob}>
880
- Edit Details
881
- </Components.Button>
882
- </Link>
883
- )}
884
- </div>
885
- );
886
- }
887
-
888
- renderAttachment(attachment, index, onRemove) {
889
- if (!attachment) return null;
890
-
891
- return (
892
- <Components.Attachment
893
- key={index}
894
- uploading={attachment.Uploading}
895
- source={attachment.Source}
896
- title={attachment.Title}
897
- onRemove={onRemove ? () => onRemove(attachment) : undefined}
898
- />
899
- );
900
- }
901
-
902
- renderAddNotePopup() {
903
- if (!this.state.addNoteOpen) return null;
904
-
905
- if (this.state.submittingNote) {
906
- return (
907
- <Components.Popup title="Saving Note" maxWidth={600} hasPadding>
908
- <div className="flex flex-center-row">
909
- <FontAwesome className="spinner" name="spinner fa-pulse fa-fw" />
910
- </div>
911
- </Components.Popup>
912
- );
913
- }
914
-
915
- return (
916
- <Components.Popup
917
- title={`${this.state.editingNote ? 'Edit' : 'Add'} Note`}
918
- onClose={this.onCloseAddNote}
919
- maxWidth={600}
920
- hasPadding
921
- buttons={[
922
- {
923
- type: 'primary',
924
- onClick: this.onConfirmAddNote,
925
- isActive: this.isReadyToSaveNote(),
926
- text: 'Save',
927
- },
928
- {
929
- type: 'tertiary',
930
- onClick: this.onCloseAddNote,
931
- isActive: true,
932
- text: 'Cancel',
933
- },
934
- ]}
935
- >
936
- <Components.GenericInput
937
- id="noteInput"
938
- type="textarea"
939
- componentClass="textarea"
940
- value={this.state.noteInput}
941
- placeholder="Enter note"
942
- onChange={(e) => this.onHandleChange(e)}
943
- inputStyle={{
944
- width: 400,
945
- }}
946
- />
947
- <Components.Text type="h5">Attachments</Components.Text>
948
- {this.state.noteAttachments.map((a, i) => this.renderAttachment(a, i, this.onRemoveAttachment))}
949
- <input
950
- ref={(input) => (this.attachmentInput = input)}
951
- id="attachmentInput"
952
- type="file"
953
- className="fileInput"
954
- onChange={(e) => this.onHandlePDFFileChange(e)}
955
- accept="application/pdf"
956
- />
957
- <div
958
- className="iconTextButton marginBottom-16"
959
- onClick={() => {
960
- this.attachmentInput.click();
961
- }}
962
- >
963
- <FontAwesome className="iconTextButton_icon" name="paperclip" />
964
- <p className="iconTextButton_text">Add Attachment</p>
965
- </div>
966
- <Components.ImageInput
967
- ref={(ref) => {
968
- this.imageInput = ref;
969
- }}
970
- multiple
971
- refreshCallback={(images) => {
972
- this.setState({ noteImages: images });
973
- }}
974
- />
975
- </Components.Popup>
976
- );
977
- }
978
-
979
- renderUsers() {
980
- let content = null;
981
- if (this.state.confirmingAssignee) {
982
- content = (
983
- <div className="flex flex-center-row">
984
- <FontAwesome className="spinner" name="spinner fa-pulse fa-fw" />
985
- </div>
986
- );
987
- } else if (this.state.selectedAssignee) {
988
- content = (
989
- <div>
990
- <Components.UserListing
991
- key={this.state.selectedAssignee.id}
992
- user={this.state.selectedAssignee}
993
- rightContent={
994
- <Components.SVGIcon
995
- className="removeIcon"
996
- icon="close"
997
- onClick={() => {
998
- this.onSelectAssignee();
999
- }}
1000
- colour={Colours.COLOUR_DUSK}
1001
- />
1002
- }
1003
- />
1004
- </div>
1005
- );
1006
- } else {
1007
- content = (
1008
- <div>
1009
- <Components.GenericInput
1010
- id="userSearch"
1011
- type="text"
1012
- // label="Search"
1013
- placeholder="Search name"
1014
- value={this.state.userSearch}
1015
- onChange={(e) => this.onHandleChange(e)}
1016
- alwaysShowLabel
1017
- />
1018
- {_.sortBy(this.state.assignees, (u) => u.displayName.toUpperCase())
1019
- .filter((u) => {
1020
- if (_.isEmpty(this.state.userSearch)) return true;
1021
- return u.displayName.toUpperCase().indexOf(this.state.userSearch.toUpperCase()) > -1;
1022
- })
1023
- .map((user) => {
1024
- return (
1025
- <Components.UserListing
1026
- key={user.id}
1027
- user={user}
1028
- onClick={() => {
1029
- this.onSelectAssignee(user);
1030
- }}
1031
- />
1032
- );
1033
- })}
1034
- </div>
1035
- );
1036
- }
1037
- return (
1038
- <div className="genericInputContainer">
1039
- <Components.Text type="formLabel">Select User</Components.Text>
1040
- {content}
1041
- </div>
1042
- );
1043
- }
1044
-
1045
- renderUserSelectionPopup() {
1046
- if (!this.state.showingAssigneeSelector) return null;
1047
- return (
1048
- <Components.Popup
1049
- title="Assign Job"
1050
- onClose={this.onCloseSelectAssignee}
1051
- maxWidth={600}
1052
- hasPadding
1053
- buttons={[
1054
- {
1055
- type: 'primary',
1056
- onClick: this.onConfirmAssignee,
1057
- isActive: !!this.state.selectedAssignee,
1058
- text: 'Confirm',
1059
- },
1060
- {
1061
- type: 'tertiary',
1062
- onClick: this.onCloseSelectAssignee,
1063
- isActive: true,
1064
- text: 'Cancel',
1065
- },
1066
- ]}
1067
- >
1068
- {this.renderUsers()}
1069
- </Components.Popup>
1070
- );
1071
- }
1072
-
1073
- render() {
1074
- return (
1075
- <Components.OverlayPage>
1076
- {this.renderAddNotePopup()}
1077
- {this.renderUserSelectionPopup()}
1078
- <Components.OverlayPageContents>
1079
- <Components.OverlayPageSection className="pageSectionWrapper--fixedPopupSize">{this.renderInner()}</Components.OverlayPageSection>
1080
- <Components.OverlayPageSection className="pageSectionWrapper--newPopupSide pageSectionWrapper--newPopupSide-fixedWidth">
1081
- {this.renderAssignment()}
1082
- {this.renderPriority()}
1083
- {this.renderOverview()}
1084
- </Components.OverlayPageSection>
1085
- </Components.OverlayPageContents>
1086
- <Components.OverlayPageBottomButtons>{this.renderButtons()}</Components.OverlayPageBottomButtons>
1087
- </Components.OverlayPage>
1088
- );
1089
- }
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
+ }
1090
1522
  }
1091
1523
 
1092
1524
  const mapStateToProps = (state) => {
1093
- const { auth } = state;
1094
- return { auth, statusTypes: state[values.reducerKey].jobstatuses };
1525
+ const { auth } = state;
1526
+ return { auth, statusTypes: state[values.reducerKey].jobstatuses };
1095
1527
  };
1096
1528
 
1097
- export default connect(mapStateToProps, { jobsLoaded, jobStatusesUpdate })(withRouter(Job));
1529
+ export default connect(mapStateToProps, { jobsLoaded, jobStatusesUpdate })(
1530
+ withRouter(Job),
1531
+ );