oceanhelm 0.0.9 → 0.0.10

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.
@@ -5,14 +5,14 @@
5
5
 
6
6
  <div class="crew-section">
7
7
  <div class="crew-section-header">
8
- <h2>{{ sectionTitle }}</h2>
8
+ <h4 class="crew-subhead">{{ sectionTitle }}</h4>
9
9
  <button class="btn btn-primary" @click="handleToggleAddForm" v-if="canAddCrew">
10
10
  {{ showAddForm ? 'Cancel' : '+ Add Crew Member' }}
11
11
  </button>
12
12
  </div>
13
13
 
14
- <!-- Search and Filter -->
15
- <div class="search-filter">
14
+ <!-- Search and Filter - Hide when form is shown -->
15
+ <div class="search-filter" v-if="!showAddForm">
16
16
  <input type="text" placeholder="Search crew by name or role..." v-model="searchQuery" @input="handleSearch">
17
17
  <select v-model="filterStatus" @change="handleFilter">
18
18
  <option value="all">All Statuses</option>
@@ -20,18 +20,106 @@
20
20
  <option value="onduty">On Duty</option>
21
21
  <option value="unavailable">Unavailable</option>
22
22
  </select>
23
+ <button class="btn btn-secondary" @click="showTimesheet = !showTimesheet">
24
+ <i :class="showTimesheet ? 'bi bi-grid-3x3-gap' : 'bi bi-table'"></i>
25
+ {{ showTimesheet ? 'Show Crew Cards' : 'Show Timesheet' }}
26
+ </button>
23
27
  </div>
24
28
 
25
- <!-- Loading State -->
26
- <div v-if="loading" class="loading-state">
29
+ <!-- Loading State - Hide when form is shown -->
30
+ <div v-if="loading && !showAddForm" class="loading-state">
27
31
  <div class="spinner-border text-primary" role="status">
28
32
  <span class="visually-hidden">Loading crew...</span>
29
33
  </div>
30
34
  <p>Loading crew members...</p>
31
35
  </div>
32
36
 
33
- <!-- Crew Grid -->
34
- <div class="crew-grid" v-else-if="filteredCrew.length > 0">
37
+ <!-- Consolidated Timesheet View - Hide when form is shown -->
38
+ <div v-else-if="showTimesheet && !showAddForm" class="timesheet-view">
39
+ <div class="timesheet-header">
40
+ <h3><i class="bi bi-table"></i> Crew Activity Timesheet</h3>
41
+ <div class="timesheet-controls">
42
+ <select v-model="timesheetFilter" @change="filterTimesheet">
43
+ <option value="all">All Activities</option>
44
+ <option value="Embarked">Embarked Only</option>
45
+ <option value="Deboarded">Deboarded Only</option>
46
+ <option value="Assigned">Assigned Only</option>
47
+ <option value="Status Changed">Status Changes Only</option>
48
+ </select>
49
+ <input type="text" v-model="timesheetSearch" placeholder="Search timesheet..."
50
+ class="timesheet-search">
51
+ </div>
52
+ </div>
53
+
54
+ <div class="timesheet-summary">
55
+ <div class="summary-card">
56
+ <span class="summary-label">Total Entries:</span>
57
+ <span class="summary-value">{{ filteredTimesheetEntries.length }}</span>
58
+ </div>
59
+ <div class="summary-card">
60
+ <span class="summary-label">Crew Members:</span>
61
+ <span class="summary-value">{{ uniqueCrewCount }}</span>
62
+ </div>
63
+ <div class="summary-card">
64
+ <span class="summary-label">Date Range:</span>
65
+ <span class="summary-value">{{ timesheetDateRange }}</span>
66
+ </div>
67
+ </div>
68
+
69
+ <div v-if="filteredTimesheetEntries.length === 0" class="no-results">
70
+ No timesheet entries found.
71
+ </div>
72
+
73
+ <div v-else class="timesheet-table-container">
74
+ <table class="timesheet-table">
75
+ <thead>
76
+ <tr>
77
+ <th @click="sortTimesheet('timestamp')">
78
+ Date/Time
79
+ <i class="bi bi-arrow-down-up"></i>
80
+ </th>
81
+ <th @click="sortTimesheet('crewName')">
82
+ Crew Member
83
+ <i class="bi bi-arrow-down-up"></i>
84
+ </th>
85
+ <th @click="sortTimesheet('role')">
86
+ Role
87
+ <i class="bi bi-arrow-down-up"></i>
88
+ </th>
89
+ <th @click="sortTimesheet('action')">
90
+ Action
91
+ <i class="bi bi-arrow-down-up"></i>
92
+ </th>
93
+ <th>Vessel</th>
94
+ <th>Duration</th>
95
+ <th>Notes</th>
96
+ </tr>
97
+ </thead>
98
+ <tbody>
99
+ <tr v-for="entry in filteredTimesheetEntries" :key="entry.uniqueId"
100
+ :class="getTimesheetRowClass(entry.action)">
101
+ <td class="timestamp-cell">{{ formatLogDate(entry.timestamp) }}</td>
102
+ <td class="crew-name-cell">
103
+ <strong>{{ entry.crewName }}</strong>
104
+ </td>
105
+ <td class="role-cell">{{ entry.role }}</td>
106
+ <td class="action-cell">
107
+ <span :class="['action-badge', getLogActionClass(entry.action)]">
108
+ <i :class="getLogIcon(entry.action)"></i>
109
+ {{ entry.action }}
110
+ </span>
111
+ </td>
112
+ <td class="vessel-cell">{{ entry.vessel || 'N/A' }}</td>
113
+ <td class="duration-cell">{{ entry.duration ? entry.duration + ' days' : 'N/A' }}</td>
114
+ <td class="notes-cell">{{ entry.notes || '-' }}</td>
115
+ </tr>
116
+ </tbody>
117
+ </table>
118
+ </div>
119
+ </div>
120
+
121
+ <!-- Crew Grid - Hide when form is shown -->
122
+ <div class="crew-grid" v-else-if="filteredCrew.length > 0 && !showAddForm">
35
123
  <div v-for="member in filteredCrew" :key="member.id" class="crew-card"
36
124
  :class="{ 'unavailable': member.status === 'unavailable' }">
37
125
  <div :class="['crew-status-badge', getStatusClass(member.status)]">
@@ -67,18 +155,48 @@
67
155
  <div v-else class="status-unavailable crew-availability vcard">
68
156
  Vessel: Unassigned
69
157
  </div>
70
- <button class="btn btn-primary" @click="handleAssignShift(member)" v-if="canAssignShift">
158
+ <button class="btn btn-primary" @click="handleAssignShift(member)"
159
+ v-if="canAssignShift && member.status !== 'onduty'">
71
160
  Assign Shift
72
161
  </button>
162
+ <button class="btn btn-warning" @click="handleDeboardCrew(member)"
163
+ v-if="canDeboardCrew && member.status === 'onduty'">
164
+ Deboard
165
+ </button>
166
+ </div>
167
+
168
+ <!-- Crew Log Button and Delete Icon -->
169
+ <div class="crew-footer">
170
+ <div class="crew-log-toggle" v-if="member.log && member.log.length > 0">
171
+ <button class="btn btn-link btn-sm log-toggle-btn" @click="toggleCrewLog(member.id)">
172
+ <i class="bi bi-clock-history"></i> View Log ({{ member.log.length }})
173
+ </button>
174
+ </div>
175
+ <i class="bi bi-trash icon delete-icon" @click="handleDeleteCrew(member)" v-if="canDeleteCrew"></i>
176
+ </div>
177
+
178
+ <!-- Crew Log Display -->
179
+ <div v-if="expandedLogs.includes(member.id)" class="crew-log">
180
+ <h4>Crew Activity Log</h4>
181
+ <div v-for="(entry, index) in member.log" :key="index" class="log-entry">
182
+ <div class="log-date">{{ formatLogDate(entry.timestamp) }}</div>
183
+ <div class="log-action" :class="getLogActionClass(entry.action)">
184
+ <i :class="getLogIcon(entry.action)"></i>
185
+ {{ entry.action }}
186
+ </div>
187
+ <div class="log-details">
188
+ <span v-if="entry.vessel">Vessel: {{ entry.vessel }}</span>
189
+ <span v-if="entry.duration"> | Duration: {{ entry.duration }} days</span>
190
+ </div>
191
+ <div v-if="entry.notes" class="log-notes">{{ entry.notes }}</div>
192
+ </div>
73
193
  </div>
74
194
 
75
- <!-- Delete Button -->
76
- <i class="bi bi-trash icon delete-icon" @click="handleDeleteCrew(member)" v-if="canDeleteCrew"></i>
77
195
  </div>
78
196
  </div>
79
197
 
80
- <!-- No Results -->
81
- <div v-else-if="!loading" class="no-results">
198
+ <!-- No Results - Hide when form is shown -->
199
+ <div v-else-if="!loading && !showTimesheet && !showAddForm" class="no-results">
82
200
  {{ crew.length === 0 ? 'No crew members found.' : 'No crew members found matching your search criteria.' }}
83
201
  </div>
84
202
 
@@ -128,14 +246,38 @@
128
246
  <div v-for="(cert, index) in newCrew.certifications" :key="index" class="certification-entry">
129
247
  <div class="form-row">
130
248
  <div class="form-group">
131
- <label>Certification Name</label>
249
+ <label>Certification Name *</label>
132
250
  <input type="text" v-model="cert.name" placeholder="Enter certification name">
133
251
  </div>
134
252
  <div class="form-group">
135
- <label>Expiry Date</label>
253
+ <label>Expiry Date *</label>
136
254
  <input type="date" v-model="cert.expiryDate">
137
255
  </div>
138
256
  <div class="form-group">
257
+ <label>Certificate Image *</label>
258
+ <div class="image-upload-wrapper">
259
+ <input type="file" :id="'cert-image-' + index"
260
+ @change="handleCertImageUpload($event, index)" accept="image/*,.pdf"
261
+ class="file-input">
262
+ <label :for="'cert-image-' + index" class="file-input-label">
263
+ <i class="bi bi-cloud-upload"></i>
264
+ {{ cert.imagePreview ? 'Change File' : 'Upload File' }}
265
+ </label>
266
+ <div v-if="cert.imagePreview" class="image-preview">
267
+ <img v-if="cert.imageType !== 'pdf'" :src="cert.imagePreview"
268
+ alt="Certificate preview">
269
+ <div v-else class="pdf-preview">
270
+ <i class="bi bi-file-pdf"></i>
271
+ <span>{{ cert.imageName }}</span>
272
+ </div>
273
+ <button type="button" class="btn-remove-image" @click="removeCertImage(index)">
274
+ <i class="bi bi-x-circle"></i>
275
+ </button>
276
+ </div>
277
+ <small v-if="cert.imageName" class="file-name">{{ cert.imageName }}</small>
278
+ </div>
279
+ </div>
280
+ <div class="form-group" style="display: flex; align-items: flex-end;">
139
281
  <button type="button" class="btn btn-danger btn-sm" @click="removeCertification(index)"
140
282
  v-if="newCrew.certifications.length > 1">
141
283
  Remove
@@ -172,7 +314,6 @@ export default {
172
314
  name: 'CrewManagement',
173
315
 
174
316
  props: {
175
- // Required props
176
317
  crew: {
177
318
  type: Array,
178
319
  required: true,
@@ -186,8 +327,6 @@ export default {
186
327
  type: Object,
187
328
  default: () => ({ role: 'viewer' })
188
329
  },
189
-
190
- // Optional props
191
330
  loading: {
192
331
  type: Boolean,
193
332
  default: false
@@ -196,8 +335,6 @@ export default {
196
335
  type: Array,
197
336
  default: () => ['Captain', 'First Officer', 'Engineer', 'Deckhand', 'Mechanic', 'Cook']
198
337
  },
199
-
200
- // Configuration props
201
338
  config: {
202
339
  type: Object,
203
340
  default: () => ({
@@ -216,11 +353,13 @@ export default {
216
353
  'crew-edit',
217
354
  'crew-delete',
218
355
  'crew-assign-shift',
356
+ 'crew-deboard',
219
357
  'crew-add-certification',
220
358
  'crew-view-certification',
221
359
  'search-changed',
222
360
  'filter-changed',
223
- 'access-denied'
361
+ 'access-denied',
362
+ 'upload-cert-image'
224
363
  ],
225
364
 
226
365
  data() {
@@ -229,13 +368,26 @@ export default {
229
368
  filterStatus: 'all',
230
369
  showAddForm: false,
231
370
  formErrors: {},
371
+ expandedLogs: [],
372
+ showTimesheet: false,
373
+ timesheetFilter: 'all',
374
+ timesheetSearch: '',
375
+ timesheetSortKey: 'timestamp',
376
+ timesheetSortOrder: 'desc',
232
377
  newCrew: {
233
378
  name: '',
234
379
  role: 'Deckhand',
235
380
  customRole: '',
236
381
  status: 'available',
237
382
  nextShift: '',
238
- certifications: [{ name: '', expiryDate: '' }],
383
+ certifications: [{
384
+ name: '',
385
+ expiryDate: '',
386
+ imageFile: null,
387
+ imagePreview: null,
388
+ imageName: '',
389
+ imageType: ''
390
+ }],
239
391
  notes: '',
240
392
  email: '',
241
393
  onBoard: ''
@@ -261,6 +413,68 @@ export default {
261
413
  })
262
414
  },
263
415
 
416
+ allTimesheetEntries() {
417
+ const entries = []
418
+
419
+ this.crew.forEach(member => {
420
+ if (member.log && member.log.length > 0) {
421
+ member.log.forEach((logEntry, index) => {
422
+ entries.push({
423
+ ...logEntry,
424
+ crewName: member.name,
425
+ role: member.role,
426
+ crewId: member.id,
427
+ uniqueId: `${member.id}-${index}`
428
+ })
429
+ })
430
+ }
431
+ })
432
+
433
+ return entries.sort((a, b) => {
434
+ const dateA = new Date(a.timestamp)
435
+ const dateB = new Date(b.timestamp)
436
+ return this.timesheetSortOrder === 'desc' ? dateB - dateA : dateA - dateB
437
+ })
438
+ },
439
+
440
+ filteredTimesheetEntries() {
441
+ let filtered = this.allTimesheetEntries
442
+
443
+ if (this.timesheetFilter !== 'all') {
444
+ filtered = filtered.filter(entry => entry.action === this.timesheetFilter)
445
+ }
446
+
447
+ if (this.timesheetSearch) {
448
+ const search = this.timesheetSearch.toLowerCase()
449
+ filtered = filtered.filter(entry =>
450
+ entry.crewName.toLowerCase().includes(search) ||
451
+ entry.role.toLowerCase().includes(search) ||
452
+ entry.action.toLowerCase().includes(search) ||
453
+ (entry.vessel && entry.vessel.toLowerCase().includes(search)) ||
454
+ (entry.notes && entry.notes.toLowerCase().includes(search))
455
+ )
456
+ }
457
+
458
+ return this.sortTimesheetEntries(filtered)
459
+ },
460
+
461
+ uniqueCrewCount() {
462
+ const uniqueCrewIds = new Set(this.filteredTimesheetEntries.map(e => e.crewId))
463
+ return uniqueCrewIds.size
464
+ },
465
+
466
+ timesheetDateRange() {
467
+ if (this.filteredTimesheetEntries.length === 0) return 'N/A'
468
+
469
+ const dates = this.filteredTimesheetEntries.map(e => new Date(e.timestamp))
470
+ const minDate = new Date(Math.min(...dates))
471
+ const maxDate = new Date(Math.max(...dates))
472
+
473
+ const formatDate = (date) => date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
474
+
475
+ return `${formatDate(minDate)} - ${formatDate(maxDate)}`
476
+ },
477
+
264
478
  canAddCrew() {
265
479
  return this.config.enableAdd && this.hasPermission('add')
266
480
  },
@@ -275,11 +489,14 @@ export default {
275
489
 
276
490
  canAssignShift() {
277
491
  return this.config.enableAssignShift && this.hasPermission('assign')
492
+ },
493
+
494
+ canDeboardCrew() {
495
+ return this.config.enableAssignShift && this.hasPermission('assign')
278
496
  }
279
497
  },
280
498
 
281
499
  methods: {
282
- // Permission checking
283
500
  hasPermission(action) {
284
501
  const { role, vessel } = this.userProfile
285
502
  const isVessel = vessel === this.vesselName
@@ -287,13 +504,12 @@ export default {
287
504
  const permissions = {
288
505
  'owner': ['add', 'edit', 'delete', 'assign', 'view'],
289
506
  'staff': ['add', 'edit', 'assign', 'view'],
290
- 'captain': ['assign', 'view'], // default for captain
507
+ 'captain': ['assign', 'view'],
291
508
  'viewer': ['view']
292
509
  }
293
510
 
294
511
  let rolePermissions = permissions[role] || []
295
512
 
296
- // Special case: captain + isVessel = true
297
513
  if (role === 'captain' && isVessel) {
298
514
  rolePermissions = [...rolePermissions, 'add']
299
515
  }
@@ -301,7 +517,6 @@ export default {
301
517
  return rolePermissions.includes(action)
302
518
  },
303
519
 
304
- // Event handlers
305
520
  handleToggleAddForm() {
306
521
  if (!this.canAddCrew) {
307
522
  this.$emit('access-denied', { action: 'add crew', userProfile: this.userProfile })
@@ -321,22 +536,95 @@ export default {
321
536
  this.$emit('filter-changed', this.filterStatus)
322
537
  },
323
538
 
324
- handleAddCrewMember() {
539
+ filterTimesheet() {
540
+ // Trigger re-computation of filtered entries
541
+ },
542
+
543
+ sortTimesheet(key) {
544
+ if (this.timesheetSortKey === key) {
545
+ this.timesheetSortOrder = this.timesheetSortOrder === 'asc' ? 'desc' : 'asc'
546
+ } else {
547
+ this.timesheetSortKey = key
548
+ this.timesheetSortOrder = 'asc'
549
+ }
550
+ },
551
+
552
+ sortTimesheetEntries(entries) {
553
+ const sorted = [...entries]
554
+
555
+ sorted.sort((a, b) => {
556
+ let valA, valB
557
+
558
+ switch (this.timesheetSortKey) {
559
+ case 'timestamp':
560
+ valA = new Date(a.timestamp)
561
+ valB = new Date(b.timestamp)
562
+ break
563
+ case 'crewName':
564
+ valA = a.crewName.toLowerCase()
565
+ valB = b.crewName.toLowerCase()
566
+ break
567
+ case 'role':
568
+ valA = a.role.toLowerCase()
569
+ valB = b.role.toLowerCase()
570
+ break
571
+ case 'action':
572
+ valA = a.action.toLowerCase()
573
+ valB = b.action.toLowerCase()
574
+ break
575
+ default:
576
+ return 0
577
+ }
578
+
579
+ if (valA < valB) return this.timesheetSortOrder === 'asc' ? -1 : 1
580
+ if (valA > valB) return this.timesheetSortOrder === 'asc' ? 1 : -1
581
+ return 0
582
+ })
583
+
584
+ return sorted
585
+ },
586
+
587
+ getTimesheetRowClass(action) {
588
+ return `timesheet-row-${action.toLowerCase().replace(/\s+/g, '-')}`
589
+ },
590
+
591
+ async handleAddCrewMember() {
325
592
  if (!this.validateForm()) {
326
593
  return
327
594
  }
328
595
 
329
596
  const validCertifications = this.newCrew.certifications.filter(cert =>
330
- cert.name.trim() !== '' && cert.expiryDate !== ''
597
+ cert.name.trim() !== '' && cert.expiryDate !== '' && cert.imageFile !== null
331
598
  )
332
599
 
600
+ if (validCertifications.length === 0) {
601
+ alert('Please add at least one complete certification with name, expiry date, and image.')
602
+ return
603
+ }
604
+
333
605
  const finalRole = this.newCrew.role === 'Other' ? this.newCrew.customRole : this.newCrew.role
334
606
 
607
+ // Upload all certification images first and get their URLs
608
+ const certificationsWithUrls = await Promise.all(
609
+ validCertifications.map(async (cert, index) => {
610
+ // Emit and wait for the upload to complete
611
+ const uploadResult = await this.uploadCertificationImage(cert.imageFile, this.newCrew.email)
612
+
613
+ return {
614
+ name: cert.name,
615
+ expiryDate: cert.expiryDate,
616
+ imageName: cert.imageName,
617
+ imageType: cert.imageType,
618
+ imageUrl: uploadResult.publicUrl // Add the public URL here
619
+ }
620
+ })
621
+ )
622
+
335
623
  const newMember = {
336
624
  name: this.newCrew.name,
337
625
  role: finalRole,
338
626
  status: this.newCrew.status,
339
- certifications: validCertifications,
627
+ certifications: certificationsWithUrls,
340
628
  notes: this.newCrew.notes,
341
629
  vessel: this.vesselName,
342
630
  email: this.newCrew.email,
@@ -349,6 +637,24 @@ export default {
349
637
  this.showAddForm = false
350
638
  },
351
639
 
640
+ // Add this helper method to handle the upload:
641
+ async uploadCertificationImage(file, email) {
642
+ // Return a promise that resolves when upload completes
643
+ return new Promise((resolve, reject) => {
644
+ this.$emit('upload-cert-image', {
645
+ file,
646
+ email,
647
+ callback: (result) => {
648
+ if (result.error) {
649
+ reject(result.error)
650
+ } else {
651
+ resolve(result)
652
+ }
653
+ }
654
+ })
655
+ })
656
+ },
657
+
352
658
  handleCancelForm() {
353
659
  this.showAddForm = false
354
660
  this.resetForm()
@@ -372,6 +678,32 @@ export default {
372
678
  this.$emit('crew-assign-shift', member)
373
679
  },
374
680
 
681
+ handleDeboardCrew(member) {
682
+ if (!this.canDeboardCrew) {
683
+ this.$emit('access-denied', { action: 'deboard crew', userProfile: this.userProfile })
684
+ return
685
+ }
686
+
687
+ let duration = null
688
+ if (member.nextShift) {
689
+ const embarkDate = new Date(member.nextShift)
690
+ const today = new Date()
691
+ const diffTime = Math.abs(today - embarkDate)
692
+ duration = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
693
+ }
694
+
695
+ this.$emit('crew-deboard', { member, duration })
696
+ },
697
+
698
+ toggleCrewLog(memberId) {
699
+ const index = this.expandedLogs.indexOf(memberId)
700
+ if (index > -1) {
701
+ this.expandedLogs.splice(index, 1)
702
+ } else {
703
+ this.expandedLogs.push(memberId)
704
+ }
705
+ },
706
+
375
707
  handleAddCertification(member) {
376
708
  if (!this.canEditCrew) {
377
709
  this.$emit('access-denied', { action: 'add certification', userProfile: this.userProfile })
@@ -385,7 +717,6 @@ export default {
385
717
  this.$emit('crew-view-certification', { certification, member })
386
718
  },
387
719
 
388
- // Form management
389
720
  resetForm() {
390
721
  this.newCrew = {
391
722
  name: '',
@@ -393,7 +724,14 @@ export default {
393
724
  customRole: '',
394
725
  status: 'available',
395
726
  nextShift: '',
396
- certifications: [{ name: '', expiryDate: '' }],
727
+ certifications: [{
728
+ name: '',
729
+ expiryDate: '',
730
+ imageFile: null,
731
+ imagePreview: null,
732
+ imageName: '',
733
+ imageType: ''
734
+ }],
397
735
  notes: '',
398
736
  email: '',
399
737
  onBoard: ''
@@ -409,19 +747,16 @@ export default {
409
747
  email: 'Email Address'
410
748
  }
411
749
 
412
- // Check required fields
413
750
  Object.keys(requiredFields).forEach(field => {
414
751
  if (!this.newCrew[field] || this.newCrew[field].trim() === '') {
415
752
  this.formErrors[field] = `${requiredFields[field]} is required`
416
753
  }
417
754
  })
418
755
 
419
- // Validate email format
420
756
  if (this.newCrew.email && !this.isValidEmail(this.newCrew.email)) {
421
757
  this.formErrors.email = 'Please enter a valid email address'
422
758
  }
423
759
 
424
- // Validate custom role
425
760
  if (this.newCrew.role === 'Other' && (!this.newCrew.customRole || this.newCrew.customRole.trim() === '')) {
426
761
  this.formErrors.customRole = 'Custom role is required when "Other" is selected'
427
762
  }
@@ -435,14 +770,55 @@ export default {
435
770
  },
436
771
 
437
772
  addCertificationEntry() {
438
- this.newCrew.certifications.push({ name: '', expiryDate: '' })
773
+ this.newCrew.certifications.push({
774
+ name: '',
775
+ expiryDate: '',
776
+ imageFile: null,
777
+ imagePreview: null,
778
+ imageName: '',
779
+ imageType: ''
780
+ })
439
781
  },
440
782
 
441
783
  removeCertification(index) {
442
784
  this.newCrew.certifications.splice(index, 1)
443
785
  },
444
786
 
445
- // Utility methods
787
+ handleCertImageUpload(event, index) {
788
+ const file = event.target.files[0]
789
+ if (!file) return
790
+
791
+ const cert = this.newCrew.certifications[index]
792
+ cert.imageFile = file
793
+ cert.imageName = file.name
794
+ cert.imageType = file.type.includes('pdf') ? 'pdf' : 'image'
795
+
796
+ // Create preview for images
797
+ if (cert.imageType === 'image') {
798
+ const reader = new FileReader()
799
+ reader.onload = (e) => {
800
+ cert.imagePreview = e.target.result
801
+ }
802
+ reader.readAsDataURL(file)
803
+ } else {
804
+ cert.imagePreview = 'pdf'
805
+ }
806
+ },
807
+
808
+ removeCertImage(index) {
809
+ const cert = this.newCrew.certifications[index]
810
+ cert.imageFile = null
811
+ cert.imagePreview = null
812
+ cert.imageName = ''
813
+ cert.imageType = ''
814
+
815
+ // Clear the file input
816
+ const fileInput = document.getElementById('cert-image-' + index)
817
+ if (fileInput) {
818
+ fileInput.value = ''
819
+ }
820
+ },
821
+
446
822
  formatStatus(status) {
447
823
  return status ? status.charAt(0).toUpperCase() + status.slice(1) : ''
448
824
  },
@@ -477,6 +853,38 @@ export default {
477
853
  if (expiry <= today) return 'expired'
478
854
  if (expiry <= oneMonthFromNow) return 'expiringSoon'
479
855
  return 'valid'
856
+ },
857
+
858
+ formatLogDate(timestamp) {
859
+ if (!timestamp) return ''
860
+ const date = new Date(timestamp)
861
+ return date.toLocaleDateString('en-US', {
862
+ year: 'numeric',
863
+ month: 'short',
864
+ day: 'numeric',
865
+ hour: '2-digit',
866
+ minute: '2-digit'
867
+ })
868
+ },
869
+
870
+ getLogActionClass(action) {
871
+ const actionMap = {
872
+ 'Embarked': 'log-action-embark',
873
+ 'Deboarded': 'log-action-deboard',
874
+ 'Assigned': 'log-action-assign',
875
+ 'Status Changed': 'log-action-status'
876
+ }
877
+ return actionMap[action] || ''
878
+ },
879
+
880
+ getLogIcon(action) {
881
+ const iconMap = {
882
+ 'Embarked': 'bi bi-box-arrow-in-right',
883
+ 'Deboarded': 'bi bi-box-arrow-left',
884
+ 'Assigned': 'bi bi-calendar-check',
885
+ 'Status Changed': 'bi bi-arrow-repeat'
886
+ }
887
+ return iconMap[action] || 'bi bi-circle-fill'
480
888
  }
481
889
  }
482
890
  }
@@ -496,6 +904,7 @@ export default {
496
904
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
497
905
  gap: 20px;
498
906
  margin-bottom: 30px;
907
+ align-items: start;
499
908
  }
500
909
 
501
910
  .crew-card {
@@ -540,6 +949,11 @@ export default {
540
949
  margin-right: 5px;
541
950
  margin-bottom: 5px;
542
951
  font-size: 0.85em;
952
+ cursor: pointer;
953
+ }
954
+
955
+ .certification-tag:hover {
956
+ background-color: #d1f0ff;
543
957
  }
544
958
 
545
959
  .crew-status-badge {
@@ -571,6 +985,8 @@ export default {
571
985
  display: flex;
572
986
  justify-content: space-between;
573
987
  margin-top: 15px;
988
+ gap: 5px;
989
+ flex-wrap: wrap;
574
990
  }
575
991
 
576
992
  .btn {
@@ -579,6 +995,7 @@ export default {
579
995
  cursor: pointer;
580
996
  font-size: 0.9em;
581
997
  transition: background-color 0.2s;
998
+ border: none;
582
999
  }
583
1000
 
584
1001
  .btn-primary {
@@ -599,10 +1016,140 @@ export default {
599
1016
  background-color: #dde2e6;
600
1017
  }
601
1018
 
1019
+ .btn-warning {
1020
+ background-color: #ffc107;
1021
+ color: #212529;
1022
+ }
1023
+
1024
+ .btn-warning:hover {
1025
+ background-color: #e0a800;
1026
+ }
1027
+
1028
+ .btn-danger {
1029
+ background-color: #dc3545;
1030
+ color: white;
1031
+ }
1032
+
1033
+ .btn-danger:hover {
1034
+ background-color: #c82333;
1035
+ }
1036
+
1037
+ .btn-link {
1038
+ background: none;
1039
+ border: none;
1040
+ color: #005792;
1041
+ text-decoration: none;
1042
+ padding: 4px 8px;
1043
+ }
1044
+
1045
+ .btn-link:hover {
1046
+ text-decoration: underline;
1047
+ }
1048
+
1049
+ .btn-sm {
1050
+ font-size: 0.8em;
1051
+ padding: 4px 8px;
1052
+ }
1053
+
1054
+ .crew-footer {
1055
+ display: flex;
1056
+ justify-content: space-between;
1057
+ align-items: center;
1058
+ margin-top: 10px;
1059
+ padding-top: 10px;
1060
+ border-top: 1px solid #e9ecef;
1061
+ }
1062
+
1063
+ .crew-log-toggle {
1064
+ flex: 1;
1065
+ }
1066
+
1067
+ .log-toggle-btn {
1068
+ text-align: left;
1069
+ padding: 0;
1070
+ display: flex;
1071
+ align-items: center;
1072
+ gap: 5px;
1073
+ }
1074
+
1075
+ .crew-log {
1076
+ margin-top: 15px;
1077
+ padding: 10px;
1078
+ background-color: #ffffff;
1079
+ border-radius: 4px;
1080
+ border: 1px solid #dee2e6;
1081
+ max-height: 300px;
1082
+ overflow-y: auto;
1083
+ }
1084
+
1085
+ .crew-log h4 {
1086
+ margin: 0 0 10px 0;
1087
+ font-size: 0.9em;
1088
+ color: #005792;
1089
+ border-bottom: 1px solid #dee2e6;
1090
+ padding-bottom: 5px;
1091
+ }
1092
+
1093
+ .log-entry {
1094
+ padding: 8px;
1095
+ margin-bottom: 8px;
1096
+ border-left: 3px solid #005792;
1097
+ background-color: #f8f9fa;
1098
+ border-radius: 3px;
1099
+ }
1100
+
1101
+ .log-entry:last-child {
1102
+ margin-bottom: 0;
1103
+ }
1104
+
1105
+ .log-date {
1106
+ font-size: 0.75em;
1107
+ color: #6c757d;
1108
+ margin-bottom: 3px;
1109
+ }
1110
+
1111
+ .log-action {
1112
+ font-weight: bold;
1113
+ font-size: 0.85em;
1114
+ margin-bottom: 3px;
1115
+ display: flex;
1116
+ align-items: center;
1117
+ gap: 5px;
1118
+ }
1119
+
1120
+ .log-action-embark {
1121
+ color: #28a745;
1122
+ }
1123
+
1124
+ .log-action-deboard {
1125
+ color: #dc3545;
1126
+ }
1127
+
1128
+ .log-action-assign {
1129
+ color: #007bff;
1130
+ }
1131
+
1132
+ .log-action-status {
1133
+ color: #ffc107;
1134
+ }
1135
+
1136
+ .log-details {
1137
+ font-size: 0.8em;
1138
+ color: #495057;
1139
+ }
1140
+
1141
+ .log-notes {
1142
+ font-size: 0.8em;
1143
+ color: #6c757d;
1144
+ font-style: italic;
1145
+ margin-top: 3px;
1146
+ }
1147
+
602
1148
  .search-filter {
603
1149
  display: flex;
604
1150
  margin-bottom: 20px;
605
1151
  gap: 10px;
1152
+ align-items: center;
606
1153
  }
607
1154
 
608
1155
  .search-filter input,
@@ -617,7 +1164,7 @@ export default {
617
1164
  }
618
1165
 
619
1166
  .search-filter select {
620
- width: 30%;
1167
+ min-width: 150px;
621
1168
  }
622
1169
 
623
1170
  .add-crew-form {
@@ -663,6 +1210,24 @@ export default {
663
1210
 
664
1211
  .icon {
665
1212
  font-size: 20px;
1213
+ cursor: pointer;
1214
+ color: #005792;
1215
+ }
1216
+
1217
+ .icon:hover {
1218
+ color: #003d5c;
1219
+ }
1220
+
1221
+ .delete-icon {
1222
+ font-size: 20px;
1223
+ cursor: pointer;
1224
+ color: #dc3545;
1225
+ position: static;
1226
+ margin: 0;
1227
+ }
1228
+
1229
+ .delete-icon:hover {
1230
+ color: #c82333;
666
1231
  }
667
1232
 
668
1233
  .form-group input,
@@ -674,6 +1239,16 @@ export default {
674
1239
  border-radius: 4px;
675
1240
  }
676
1241
 
1242
+ .form-group input.error {
1243
+ border-color: #dc3545;
1244
+ }
1245
+
1246
+ .error-message {
1247
+ color: #dc3545;
1248
+ font-size: 0.85em;
1249
+ margin-top: 5px;
1250
+ }
1251
+
677
1252
  .form-actions {
678
1253
  display: flex;
679
1254
  justify-content: flex-end;
@@ -683,8 +1258,9 @@ export default {
683
1258
 
684
1259
  .no-results {
685
1260
  text-align: center;
686
- padding: 20px;
1261
+ padding: 40px 20px;
687
1262
  color: #6c757d;
1263
+ font-size: 1.1em;
688
1264
  }
689
1265
 
690
1266
  .crew-section-header {
@@ -694,13 +1270,392 @@ export default {
694
1270
  margin-bottom: 15px;
695
1271
  }
696
1272
 
1273
+ .crew-section-header h2 {
1274
+ color: #005792;
1275
+ margin: 0;
1276
+ }
1277
+
697
1278
  .vcard {
698
1279
  border-radius: 6px;
699
1280
  padding: 5px;
700
1281
  }
701
1282
 
702
- #content.active {
703
- margin-left: var(--sidebar-width);
704
- width: calc(100% - var(--sidebar-width));
1283
+ .loading-state {
1284
+ text-align: center;
1285
+ padding: 40px 20px;
1286
+ }
1287
+
1288
+ .spinner-border {
1289
+ width: 3rem;
1290
+ height: 3rem;
1291
+ border: 0.25em solid currentColor;
1292
+ border-right-color: transparent;
1293
+ border-radius: 50%;
1294
+ display: inline-block;
1295
+ animation: spinner-border 0.75s linear infinite;
1296
+ }
1297
+
1298
+ @keyframes spinner-border {
1299
+ to {
1300
+ transform: rotate(360deg);
1301
+ }
1302
+ }
1303
+
1304
+ .text-primary {
1305
+ color: #005792;
1306
+ }
1307
+
1308
+ .visually-hidden {
1309
+ position: absolute;
1310
+ width: 1px;
1311
+ height: 1px;
1312
+ padding: 0;
1313
+ margin: -1px;
1314
+ overflow: hidden;
1315
+ clip: rect(0, 0, 0, 0);
1316
+ white-space: nowrap;
1317
+ border: 0;
1318
+ }
1319
+
1320
+ .certification-section {
1321
+ margin: 20px 0;
1322
+ padding: 15px;
1323
+ background-color: #ffffff;
1324
+ border-radius: 4px;
1325
+ border: 1px solid #dee2e6;
1326
+ }
1327
+
1328
+ .certification-section h3 {
1329
+ color: #005792;
1330
+ margin-top: 0;
1331
+ margin-bottom: 15px;
1332
+ font-size: 1.1em;
1333
+ }
1334
+
1335
+ .certification-entry {
1336
+ margin-bottom: 10px;
1337
+ }
1338
+
1339
+ /* Image Upload Styles */
1340
+ .image-upload-wrapper {
1341
+ position: relative;
1342
+ }
1343
+
1344
+ .file-input {
1345
+ display: none;
1346
+ }
1347
+
1348
+ .file-input-label {
1349
+ display: inline-flex;
1350
+ align-items: center;
1351
+ gap: 8px;
1352
+ padding: 8px 16px;
1353
+ background-color: #005792;
1354
+ color: white !important;
1355
+ border-radius: 4px;
1356
+ cursor: pointer;
1357
+ font-size: 0.9em;
1358
+ transition: background-color 0.2s;
1359
+ }
1360
+
1361
+ .file-input-label:hover {
1362
+ background-color: #004675;
1363
+ }
1364
+
1365
+ .file-input-label i {
1366
+ font-size: 1.1em;
1367
+ }
1368
+
1369
+ .file-name {
1370
+ display: block;
1371
+ margin-top: 5px;
1372
+ color: #6c757d;
1373
+ font-size: 0.85em;
1374
+ }
1375
+
1376
+ .image-preview {
1377
+ margin-top: 10px;
1378
+ position: relative;
1379
+ display: inline-block;
1380
+ }
1381
+
1382
+ .image-preview img {
1383
+ max-width: 200px;
1384
+ max-height: 150px;
1385
+ border-radius: 4px;
1386
+ border: 2px solid #dee2e6;
1387
+ }
1388
+
1389
+ .pdf-preview {
1390
+ display: flex;
1391
+ align-items: center;
1392
+ gap: 10px;
1393
+ padding: 15px;
1394
+ background-color: #f8f9fa;
1395
+ border: 2px solid #dee2e6;
1396
+ border-radius: 4px;
1397
+ max-width: 200px;
1398
+ }
1399
+
1400
+ .pdf-preview i {
1401
+ font-size: 2em;
1402
+ color: #dc3545;
1403
+ }
1404
+
1405
+ .pdf-preview span {
1406
+ font-size: 0.85em;
1407
+ color: #495057;
1408
+ word-break: break-word;
1409
+ }
1410
+
1411
+ .btn-remove-image {
1412
+ position: absolute;
1413
+ top: -8px;
1414
+ right: -8px;
1415
+ background-color: #dc3545;
1416
+ color: white;
1417
+ border: none;
1418
+ border-radius: 50%;
1419
+ width: 24px;
1420
+ height: 24px;
1421
+ display: flex;
1422
+ align-items: center;
1423
+ justify-content: center;
1424
+ cursor: pointer;
1425
+ padding: 0;
1426
+ transition: background-color 0.2s;
1427
+ }
1428
+
1429
+ .btn-remove-image:hover {
1430
+ background-color: #c82333;
1431
+ }
1432
+
1433
+ .btn-remove-image i {
1434
+ font-size: 1em;
1435
+ }
1436
+
1437
+ /* Timesheet Styles */
1438
+ .timesheet-view {
1439
+ background-color: #ffffff;
1440
+ border-radius: 6px;
1441
+ padding: 20px;
1442
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
1443
+ }
1444
+
1445
+ .timesheet-header {
1446
+ display: flex;
1447
+ justify-content: space-between;
1448
+ align-items: center;
1449
+ margin-bottom: 20px;
1450
+ padding-bottom: 15px;
1451
+ border-bottom: 2px solid #005792;
1452
+ }
1453
+
1454
+ .timesheet-header h3 {
1455
+ color: #005792;
1456
+ margin: 0;
1457
+ display: flex;
1458
+ align-items: center;
1459
+ gap: 10px;
1460
+ }
1461
+
1462
+ .timesheet-controls {
1463
+ display: flex;
1464
+ gap: 10px;
1465
+ }
1466
+
1467
+ .timesheet-controls select,
1468
+ .timesheet-search {
1469
+ padding: 8px 12px;
1470
+ border: 1px solid #ced4da;
1471
+ border-radius: 4px;
1472
+ font-size: 0.9em;
1473
+ }
1474
+
1475
+ .timesheet-search {
1476
+ min-width: 250px;
1477
+ }
1478
+
1479
+ .timesheet-summary {
1480
+ display: flex;
1481
+ gap: 20px;
1482
+ margin-bottom: 20px;
1483
+ flex-wrap: wrap;
1484
+ }
1485
+
1486
+ .summary-card {
1487
+ flex: 1;
1488
+ min-width: 150px;
1489
+ background-color: #f8f9fa;
1490
+ padding: 15px;
1491
+ border-radius: 6px;
1492
+ border-left: 4px solid #005792;
1493
+ }
1494
+
1495
+ .summary-label {
1496
+ display: block;
1497
+ font-size: 0.85em;
1498
+ color: #6c757d;
1499
+ margin-bottom: 5px;
1500
+ }
1501
+
1502
+ .summary-value {
1503
+ display: block;
1504
+ font-size: 1.5em;
1505
+ font-weight: bold;
1506
+ color: #005792;
1507
+ }
1508
+
1509
+ .timesheet-table-container {
1510
+ overflow-x: auto;
1511
+ margin-top: 20px;
1512
+ }
1513
+
1514
+ .timesheet-table {
1515
+ width: 100%;
1516
+ border-collapse: collapse;
1517
+ font-size: 0.9em;
1518
+ }
1519
+
1520
+ .timesheet-table thead {
1521
+ background-color: #005792;
1522
+ color: white;
1523
+ }
1524
+
1525
+ .timesheet-table th {
1526
+ padding: 12px 8px;
1527
+ text-align: left;
1528
+ font-weight: bold;
1529
+ cursor: pointer;
1530
+ user-select: none;
1531
+ }
1532
+
1533
+ .timesheet-table th:hover {
1534
+ background-color: #004675;
1535
+ }
1536
+
1537
+ .timesheet-table th i {
1538
+ font-size: 0.8em;
1539
+ margin-left: 5px;
1540
+ }
1541
+
1542
+ .timesheet-table tbody tr {
1543
+ border-bottom: 1px solid #dee2e6;
1544
+ }
1545
+
1546
+ .timesheet-table tbody tr:hover {
1547
+ background-color: #f8f9fa;
1548
+ }
1549
+
1550
+ .timesheet-table td {
1551
+ padding: 10px 8px;
1552
+ }
1553
+
1554
+ .timestamp-cell {
1555
+ white-space: nowrap;
1556
+ color: #495057;
1557
+ font-size: 0.85em;
1558
+ }
1559
+
1560
+ .crew-name-cell strong {
1561
+ color: #005792;
1562
+ }
1563
+
1564
+ .role-cell {
1565
+ color: #6c757d;
1566
+ }
1567
+
1568
+ .action-cell {
1569
+ white-space: nowrap;
1570
+ }
1571
+
1572
+ .action-badge {
1573
+ display: inline-flex;
1574
+ align-items: center;
1575
+ gap: 5px;
1576
+ padding: 4px 10px;
1577
+ border-radius: 12px;
1578
+ font-size: 0.85em;
1579
+ font-weight: bold;
1580
+ }
1581
+
1582
+ .vessel-cell {
1583
+ color: #495057;
1584
+ }
1585
+
1586
+ .duration-cell {
1587
+ color: #495057;
1588
+ font-weight: 500;
1589
+ }
1590
+
1591
+ .notes-cell {
1592
+ color: #6c757d;
1593
+ font-style: italic;
1594
+ max-width: 200px;
1595
+ overflow: hidden;
1596
+ text-overflow: ellipsis;
1597
+ }
1598
+
1599
+ .timesheet-row-embarked {
1600
+ background-color: #d4edda;
1601
+ }
1602
+
1603
+ .timesheet-row-deboarded {
1604
+ background-color: #f8d7da;
1605
+ }
1606
+
1607
+ .timesheet-row-assigned {
1608
+ background-color: #d1ecf1;
1609
+ }
1610
+
1611
+ .timesheet-row-status-changed {
1612
+ background-color: #fff3cd;
1613
+ }
1614
+
1615
+ @media (max-width: 768px) {
1616
+ .crew-grid {
1617
+ grid-template-columns: 1fr;
1618
+ }
1619
+
1620
+ .search-filter {
1621
+ flex-direction: column;
1622
+ }
1623
+
1624
+ .search-filter input,
1625
+ .search-filter select {
1626
+ width: 100%;
1627
+ }
1628
+
1629
+ .timesheet-header {
1630
+ flex-direction: column;
1631
+ align-items: flex-start;
1632
+ gap: 15px;
1633
+ }
1634
+
1635
+ .timesheet-controls {
1636
+ flex-direction: column;
1637
+ width: 100%;
1638
+ }
1639
+
1640
+ .timesheet-controls select,
1641
+ .timesheet-search {
1642
+ width: 100%;
1643
+ }
1644
+
1645
+ .timesheet-summary {
1646
+ flex-direction: column;
1647
+ }
1648
+
1649
+ .timesheet-table {
1650
+ font-size: 0.8em;
1651
+ }
1652
+
1653
+ .timesheet-table th,
1654
+ .timesheet-table td {
1655
+ padding: 8px 4px;
1656
+ }
1657
+ }
1658
+ .crew-subhead{
1659
+ color: #000;
705
1660
  }
706
1661
  </style>