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.
- package/dist/oceanhelm.es.js +1577 -876
- package/dist/oceanhelm.es.js.map +1 -1
- package/dist/oceanhelm.umd.js +1 -1
- package/dist/oceanhelm.umd.js.map +1 -1
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/components/ConfigurableSidebar.vue +1 -2
- package/src/components/CrewManagement.vue +1060 -54
- package/src/components/DashHead.vue +10 -0
- package/src/components/Reports.vue +612 -0
- package/src/index.js +3 -1
- package/src/utils/sidebarConfig.js +45 -13
|
@@ -5,33 +5,123 @@
|
|
|
5
5
|
|
|
6
6
|
<div class="crew-section">
|
|
7
7
|
<div class="crew-section-header">
|
|
8
|
-
<
|
|
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
|
-
<!--
|
|
34
|
-
<div
|
|
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
|
-
|
|
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
|
|
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
|
|
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: [{
|
|
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'],
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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:
|
|
339
|
-
certifications:
|
|
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: '
|
|
748
|
+
status: 'Unavailable',
|
|
395
749
|
nextShift: '',
|
|
396
|
-
certifications: [{
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
'
|
|
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:
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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>
|