oceanhelm 0.0.9 → 0.0.11

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