oceanhelm 0.0.1

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.
@@ -0,0 +1,746 @@
1
+ <template>
2
+ <div class="crew-management">
3
+ <!-- Wave background -->
4
+ <div class="wave-bg" v-if="config.showWaveBackground"></div>
5
+
6
+ <div class="crew-section">
7
+ <div class="section-header">
8
+ <h2>{{ sectionTitle }}</h2>
9
+ <button
10
+ class="btn btn-primary"
11
+ @click="handleToggleAddForm"
12
+ v-if="canAddCrew"
13
+ >
14
+ {{ showAddForm ? 'Cancel' : '+ Add Crew Member' }}
15
+ </button>
16
+ </div>
17
+
18
+ <!-- Search and Filter -->
19
+ <div class="search-filter">
20
+ <input
21
+ type="text"
22
+ placeholder="Search crew by name or role..."
23
+ v-model="searchQuery"
24
+ @input="handleSearch"
25
+ >
26
+ <select v-model="filterStatus" @change="handleFilter">
27
+ <option value="all">All Statuses</option>
28
+ <option value="available">Available</option>
29
+ <option value="onduty">On Duty</option>
30
+ <option value="unavailable">Unavailable</option>
31
+ </select>
32
+ </div>
33
+
34
+ <!-- Loading State -->
35
+ <div v-if="loading" class="loading-state">
36
+ <div class="spinner-border text-primary" role="status">
37
+ <span class="visually-hidden">Loading crew...</span>
38
+ </div>
39
+ <p>Loading crew members...</p>
40
+ </div>
41
+
42
+ <!-- Crew Grid -->
43
+ <div class="crew-grid" v-else-if="filteredCrew.length > 0">
44
+ <div
45
+ v-for="member in filteredCrew"
46
+ :key="member.id"
47
+ class="crew-card"
48
+ :class="{ 'unavailable': member.status === 'unavailable' }"
49
+ >
50
+ <div :class="['status-badge', getStatusClass(member.status)]">
51
+ {{ formatStatus(member.status) }}
52
+ </div>
53
+
54
+ <div class="crew-name">{{ member.name }}</div>
55
+ <div class="crew-role">{{ member.role }}</div>
56
+
57
+ <!-- Certifications -->
58
+ <div class="crew-certifications">
59
+ <div
60
+ v-for="cert in member.certifications"
61
+ :key="cert.name"
62
+ class="certification-tag"
63
+ :class="getCertificationClass(cert.expiryDate)"
64
+ @click="handleViewCertification(cert, member)"
65
+ >
66
+ {{ cert.name }}
67
+ </div>
68
+ <i
69
+ class="bi bi-patch-plus-fill icon"
70
+ @click="handleAddCertification(member)"
71
+ v-if="canEditCrew"
72
+ ></i>
73
+ </div>
74
+
75
+ <!-- Crew Details -->
76
+ <div class="crew-availability">
77
+ <strong>Embarkation Date:</strong> {{ member.nextShift || 'Not Scheduled' }}
78
+ </div>
79
+ <div class="crew-availability">
80
+ <strong>Expected Days Onboard (in days):</strong> {{ member.onBoard || 'Not Scheduled' }}
81
+ </div>
82
+
83
+ <!-- Action Buttons -->
84
+ <div class="action-buttons">
85
+ <div v-if="member.vessel" class="status-available crew-availability vcard">
86
+ Vessel: {{ member.vessel }}
87
+ </div>
88
+ <div v-else class="status-unavailable crew-availability vcard">
89
+ Vessel: Unassigned
90
+ </div>
91
+ <button
92
+ class="btn btn-primary"
93
+ @click="handleAssignShift(member)"
94
+ v-if="canAssignShift"
95
+ >
96
+ Assign Shift
97
+ </button>
98
+ </div>
99
+
100
+ <!-- Delete Button -->
101
+ <i
102
+ class="bi bi-trash icon delete-icon"
103
+ @click="handleDeleteCrew(member)"
104
+ v-if="canDeleteCrew"
105
+ ></i>
106
+ </div>
107
+ </div>
108
+
109
+ <!-- No Results -->
110
+ <div v-else-if="!loading" class="no-results">
111
+ {{ crew.length === 0 ? 'No crew members found.' : 'No crew members found matching your search criteria.' }}
112
+ </div>
113
+
114
+ <!-- Add Crew Form -->
115
+ <div class="add-crew-form" v-if="showAddForm">
116
+ <h2>Add New Crew Member</h2>
117
+
118
+ <!-- Basic Information -->
119
+ <div class="form-row">
120
+ <div class="form-group">
121
+ <label for="crew-name">Full Name *</label>
122
+ <input
123
+ type="text"
124
+ id="crew-name"
125
+ v-model="newCrew.name"
126
+ :class="{ 'error': formErrors.name }"
127
+ >
128
+ <div v-if="formErrors.name" class="error-message">{{ formErrors.name }}</div>
129
+ </div>
130
+ <div class="form-group">
131
+ <label for="crew-role">Role/Position *</label>
132
+ <select id="crew-role" v-model="newCrew.role">
133
+ <option v-for="role in availableRoles" :key="role" :value="role">
134
+ {{ role }}
135
+ </option>
136
+ <option value="Other">Other</option>
137
+ </select>
138
+ <input
139
+ v-if="newCrew.role === 'Other'"
140
+ type="text"
141
+ placeholder="Enter custom role"
142
+ v-model="newCrew.customRole"
143
+ style="margin-top: 8px;"
144
+ >
145
+ </div>
146
+ </div>
147
+
148
+ <div class="form-row">
149
+ <div class="form-group">
150
+ <label for="crew-status">Status *</label>
151
+ <select id="crew-status" v-model="newCrew.status">
152
+ <option value="available">Available</option>
153
+ <option value="onduty">On Duty</option>
154
+ <option value="unavailable">Unavailable</option>
155
+ </select>
156
+ </div>
157
+ <div class="form-group">
158
+ <label for="crew-email">Email Address *</label>
159
+ <input
160
+ type="email"
161
+ id="crew-email"
162
+ v-model="newCrew.email"
163
+ :class="{ 'error': formErrors.email }"
164
+ >
165
+ <div v-if="formErrors.email" class="error-message">{{ formErrors.email }}</div>
166
+ </div>
167
+ </div>
168
+
169
+ <!-- Certifications Section -->
170
+ <div class="certification-section">
171
+ <h3>Certifications</h3>
172
+ <div v-for="(cert, index) in newCrew.certifications" :key="index" class="certification-entry">
173
+ <div class="form-row">
174
+ <div class="form-group">
175
+ <label>Certification Name</label>
176
+ <input type="text" v-model="cert.name" placeholder="Enter certification name">
177
+ </div>
178
+ <div class="form-group">
179
+ <label>Expiry Date</label>
180
+ <input type="date" v-model="cert.expiryDate">
181
+ </div>
182
+ <div class="form-group">
183
+ <button
184
+ type="button"
185
+ class="btn btn-danger btn-sm"
186
+ @click="removeCertification(index)"
187
+ v-if="newCrew.certifications.length > 1"
188
+ >
189
+ Remove
190
+ </button>
191
+ </div>
192
+ </div>
193
+ </div>
194
+
195
+ <button type="button" class="btn btn-secondary btn-sm" @click="addCertificationEntry">
196
+ + Add More Certification
197
+ </button>
198
+ </div>
199
+
200
+ <!-- Notes -->
201
+ <div class="form-row">
202
+ <div class="form-group">
203
+ <label for="crew-notes">Notes</label>
204
+ <textarea id="crew-notes" rows="3" v-model="newCrew.notes"></textarea>
205
+ </div>
206
+ </div>
207
+
208
+ <!-- Form Actions -->
209
+ <div class="form-actions">
210
+ <button class="btn btn-secondary" @click="handleCancelForm">Cancel</button>
211
+ <button class="btn btn-primary" @click="handleAddCrewMember">Add Crew Member</button>
212
+ </div>
213
+ </div>
214
+ </div>
215
+ </div>
216
+ </template>
217
+
218
+ <script>
219
+ export default {
220
+ name: 'CrewManagement',
221
+
222
+ props: {
223
+ // Required props
224
+ crew: {
225
+ type: Array,
226
+ required: true,
227
+ default: () => []
228
+ },
229
+ vesselName: {
230
+ type: String,
231
+ default: ''
232
+ },
233
+ userProfile: {
234
+ type: Object,
235
+ default: () => ({ role: 'viewer' })
236
+ },
237
+
238
+ // Optional props
239
+ loading: {
240
+ type: Boolean,
241
+ default: false
242
+ },
243
+ availableRoles: {
244
+ type: Array,
245
+ default: () => ['Captain', 'First Officer', 'Engineer', 'Deckhand', 'Mechanic', 'Cook']
246
+ },
247
+
248
+ // Configuration props
249
+ config: {
250
+ type: Object,
251
+ default: () => ({
252
+ showWaveBackground: true,
253
+ enableAdd: true,
254
+ enableEdit: true,
255
+ enableDelete: true,
256
+ enableAssignShift: true,
257
+ enableCertificationManagement: true
258
+ })
259
+ }
260
+ },
261
+
262
+ emits: [
263
+ 'crew-add',
264
+ 'crew-edit',
265
+ 'crew-delete',
266
+ 'crew-assign-shift',
267
+ 'crew-add-certification',
268
+ 'crew-view-certification',
269
+ 'search-changed',
270
+ 'filter-changed',
271
+ 'access-denied'
272
+ ],
273
+
274
+ data() {
275
+ return {
276
+ searchQuery: '',
277
+ filterStatus: 'all',
278
+ showAddForm: false,
279
+ formErrors: {},
280
+ newCrew: {
281
+ name: '',
282
+ role: 'Deckhand',
283
+ customRole: '',
284
+ status: 'available',
285
+ nextShift: '',
286
+ certifications: [{ name: '', expiryDate: '' }],
287
+ notes: '',
288
+ email: '',
289
+ onBoard: ''
290
+ }
291
+ }
292
+ },
293
+
294
+ computed: {
295
+ sectionTitle() {
296
+ return this.vesselName ? `Current Crew for ${this.vesselName}` : 'All Fleet Crew'
297
+ },
298
+
299
+ filteredCrew() {
300
+ return this.crew.filter(member => {
301
+ const matchesSearch = this.searchQuery === '' ||
302
+ member.name.toLowerCase().includes(this.searchQuery.toLowerCase()) ||
303
+ member.role.toLowerCase().includes(this.searchQuery.toLowerCase()) ||
304
+ (member.vessel && member.vessel.toLowerCase().includes(this.searchQuery.toLowerCase()))
305
+
306
+ const matchesStatus = this.filterStatus === 'all' || member.status === this.filterStatus
307
+
308
+ return matchesSearch && matchesStatus
309
+ })
310
+ },
311
+
312
+ canAddCrew() {
313
+ return this.config.enableAdd && this.hasPermission('add')
314
+ },
315
+
316
+ canEditCrew() {
317
+ return this.config.enableEdit && this.hasPermission('edit')
318
+ },
319
+
320
+ canDeleteCrew() {
321
+ return this.config.enableDelete && this.hasPermission('delete')
322
+ },
323
+
324
+ canAssignShift() {
325
+ return this.config.enableAssignShift && this.hasPermission('assign')
326
+ }
327
+ },
328
+
329
+ methods: {
330
+ // Permission checking
331
+ hasPermission(action) {
332
+ const { role } = this.userProfile
333
+ const permissions = {
334
+ 'owner': ['add', 'edit', 'delete', 'assign', 'view'],
335
+ 'staff': ['add', 'edit', 'assign', 'view'],
336
+ 'captain': ['assign', 'view'],
337
+ 'viewer': ['view']
338
+ }
339
+ return permissions[role]?.includes(action) || false
340
+ },
341
+
342
+ // Event handlers
343
+ handleToggleAddForm() {
344
+ if (!this.canAddCrew) {
345
+ this.$emit('access-denied', { action: 'add crew', userProfile: this.userProfile })
346
+ return
347
+ }
348
+ this.showAddForm = !this.showAddForm
349
+ if (!this.showAddForm) {
350
+ this.resetForm()
351
+ }
352
+ },
353
+
354
+ handleSearch() {
355
+ this.$emit('search-changed', this.searchQuery)
356
+ },
357
+
358
+ handleFilter() {
359
+ this.$emit('filter-changed', this.filterStatus)
360
+ },
361
+
362
+ handleAddCrewMember() {
363
+ if (!this.validateForm()) {
364
+ return
365
+ }
366
+
367
+ const validCertifications = this.newCrew.certifications.filter(cert =>
368
+ cert.name.trim() !== '' && cert.expiryDate !== ''
369
+ )
370
+
371
+ const finalRole = this.newCrew.role === 'Other' ? this.newCrew.customRole : this.newCrew.role
372
+
373
+ const newMember = {
374
+ name: this.newCrew.name,
375
+ role: finalRole,
376
+ status: this.newCrew.status,
377
+ certifications: validCertifications,
378
+ notes: this.newCrew.notes,
379
+ vessel: this.vesselName,
380
+ email: this.newCrew.email,
381
+ onBoard: this.newCrew.onBoard,
382
+ nextShift: this.newCrew.nextShift
383
+ }
384
+
385
+ this.$emit('crew-add', newMember)
386
+ this.resetForm()
387
+ this.showAddForm = false
388
+ },
389
+
390
+ handleCancelForm() {
391
+ this.showAddForm = false
392
+ this.resetForm()
393
+ },
394
+
395
+ handleDeleteCrew(member) {
396
+ if (!this.canDeleteCrew) {
397
+ this.$emit('access-denied', { action: 'delete crew', userProfile: this.userProfile })
398
+ return
399
+ }
400
+
401
+ this.$emit('crew-delete', member)
402
+ },
403
+
404
+ handleAssignShift(member) {
405
+ if (!this.canAssignShift) {
406
+ this.$emit('access-denied', { action: 'assign shift', userProfile: this.userProfile })
407
+ return
408
+ }
409
+
410
+ this.$emit('crew-assign-shift', member)
411
+ },
412
+
413
+ handleAddCertification(member) {
414
+ if (!this.canEditCrew) {
415
+ this.$emit('access-denied', { action: 'add certification', userProfile: this.userProfile })
416
+ return
417
+ }
418
+
419
+ this.$emit('crew-add-certification', member)
420
+ },
421
+
422
+ handleViewCertification(certification, member) {
423
+ this.$emit('crew-view-certification', { certification, member })
424
+ },
425
+
426
+ // Form management
427
+ resetForm() {
428
+ this.newCrew = {
429
+ name: '',
430
+ role: 'Deckhand',
431
+ customRole: '',
432
+ status: 'available',
433
+ nextShift: '',
434
+ certifications: [{ name: '', expiryDate: '' }],
435
+ notes: '',
436
+ email: '',
437
+ onBoard: ''
438
+ }
439
+ this.formErrors = {}
440
+ },
441
+
442
+ validateForm() {
443
+ this.formErrors = {}
444
+
445
+ const requiredFields = {
446
+ name: 'Full Name',
447
+ email: 'Email Address'
448
+ }
449
+
450
+ // Check required fields
451
+ Object.keys(requiredFields).forEach(field => {
452
+ if (!this.newCrew[field] || this.newCrew[field].trim() === '') {
453
+ this.formErrors[field] = `${requiredFields[field]} is required`
454
+ }
455
+ })
456
+
457
+ // Validate email format
458
+ if (this.newCrew.email && !this.isValidEmail(this.newCrew.email)) {
459
+ this.formErrors.email = 'Please enter a valid email address'
460
+ }
461
+
462
+ // Validate custom role
463
+ if (this.newCrew.role === 'Other' && (!this.newCrew.customRole || this.newCrew.customRole.trim() === '')) {
464
+ this.formErrors.customRole = 'Custom role is required when "Other" is selected'
465
+ }
466
+
467
+ return Object.keys(this.formErrors).length === 0
468
+ },
469
+
470
+ isValidEmail(email) {
471
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
472
+ return emailRegex.test(email)
473
+ },
474
+
475
+ addCertificationEntry() {
476
+ this.newCrew.certifications.push({ name: '', expiryDate: '' })
477
+ },
478
+
479
+ removeCertification(index) {
480
+ this.newCrew.certifications.splice(index, 1)
481
+ },
482
+
483
+ // Utility methods
484
+ formatStatus(status) {
485
+ return status ? status.charAt(0).toUpperCase() + status.slice(1) : ''
486
+ },
487
+
488
+ getStatusClass(status) {
489
+ const statusMap = {
490
+ 'available': 'status-available',
491
+ 'onduty': 'status-onduty',
492
+ 'unavailable': 'status-unavailable'
493
+ }
494
+ return statusMap[status] || ''
495
+ },
496
+
497
+ getCertificationClass(expiryDate) {
498
+ const status = this.getExpiryStatus(expiryDate)
499
+ const classMap = {
500
+ 'expired': 'text-danger',
501
+ 'expiringSoon': 'text-warning',
502
+ 'valid': 'text-success'
503
+ }
504
+ return classMap[status] || ''
505
+ },
506
+
507
+ getExpiryStatus(dateStr) {
508
+ if (!dateStr) return 'none'
509
+
510
+ const expiry = new Date(dateStr)
511
+ const today = new Date()
512
+ const oneMonthFromNow = new Date()
513
+ oneMonthFromNow.setMonth(today.getMonth() + 1)
514
+
515
+ if (expiry <= today) return 'expired'
516
+ if (expiry <= oneMonthFromNow) return 'expiringSoon'
517
+ return 'valid'
518
+ }
519
+ }
520
+ }
521
+ </script>
522
+
523
+ <style>
524
+ h1,
525
+ h2 {
526
+ color: #005792;
527
+ }
528
+
529
+ .crew-section {
530
+ margin-bottom: 30px;
531
+ }
532
+
533
+ .crew-grid {
534
+ display: grid;
535
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
536
+ gap: 20px;
537
+ margin-bottom: 30px;
538
+ }
539
+
540
+ .crew-card {
541
+ background-color: #f8f9fa;
542
+ border-radius: 6px;
543
+ padding: 15px;
544
+ box-shadow: 0 1px 5px rgba(0, 0, 0, 0.08);
545
+ border-left: 4px solid #00a8e8;
546
+ position: relative;
547
+ }
548
+
549
+ .crew-card.unavailable {
550
+ border-left-color: #dc3545;
551
+ opacity: 0.8;
552
+ }
553
+
554
+ .crew-name {
555
+ font-weight: bold;
556
+ font-size: 1.1em;
557
+ margin-bottom: 5px;
558
+ color: #005792;
559
+ }
560
+
561
+ .crew-role {
562
+ color: #6c757d;
563
+ font-size: 0.9em;
564
+ margin-bottom: 10px;
565
+ }
566
+
567
+ .crew-certifications,
568
+ .crew-availability {
569
+ font-size: 0.85em;
570
+ margin: 5px 0;
571
+ }
572
+
573
+ .certification-tag {
574
+ display: inline-block;
575
+ background-color: #e6f7ff;
576
+ color: #005792;
577
+ border-radius: 4px;
578
+ padding: 2px 8px;
579
+ margin-right: 5px;
580
+ margin-bottom: 5px;
581
+ font-size: 0.85em;
582
+ }
583
+
584
+ .status-badge {
585
+ position: absolute;
586
+ top: 15px;
587
+ right: 15px;
588
+ font-size: 0.75em;
589
+ padding: 3px 8px;
590
+ border-radius: 12px;
591
+ font-weight: bold;
592
+ }
593
+
594
+ .status-available {
595
+ background-color: #d4edda;
596
+ color: #155724;
597
+ }
598
+
599
+ .status-unavailable {
600
+ background-color: #f8d7da;
601
+ color: #721c24;
602
+ }
603
+
604
+ .status-onduty {
605
+ background-color: #cce5ff;
606
+ color: #004085;
607
+ }
608
+
609
+ .action-buttons {
610
+ display: flex;
611
+ justify-content: space-between;
612
+ margin-top: 15px;
613
+ }
614
+
615
+ .btn {
616
+ padding: 6px 12px;
617
+ border-radius: 4px;
618
+ border: none;
619
+ cursor: pointer;
620
+ font-size: 0.9em;
621
+ transition: background-color 0.2s;
622
+ }
623
+
624
+ .btn-primary {
625
+ background-color: #005792;
626
+ color: white;
627
+ }
628
+
629
+ .btn-primary:hover {
630
+ background-color: #004675;
631
+ }
632
+
633
+ .btn-secondary {
634
+ background-color: #e9ecef;
635
+ color: #495057;
636
+ }
637
+
638
+ .btn-secondary:hover {
639
+ background-color: #dde2e6;
640
+ }
641
+
642
+ .search-filter {
643
+ display: flex;
644
+ margin-bottom: 20px;
645
+ gap: 10px;
646
+ }
647
+
648
+ .search-filter input,
649
+ .search-filter select {
650
+ padding: 8px 12px;
651
+ border: 1px solid #ced4da;
652
+ border-radius: 4px;
653
+ }
654
+
655
+ .search-filter input {
656
+ flex-grow: 1;
657
+ }
658
+
659
+ .search-filter select {
660
+ width: 30%;
661
+ }
662
+
663
+ .add-crew-form {
664
+ background-color: #f8f9fa;
665
+ padding: 20px;
666
+ border-radius: 6px;
667
+ margin-top: 30px;
668
+ }
669
+
670
+ .form-row {
671
+ display: flex;
672
+ flex-wrap: wrap;
673
+ gap: 15px;
674
+ margin-bottom: 15px;
675
+ }
676
+
677
+ .form-group {
678
+ flex: 1;
679
+ min-width: 200px;
680
+ }
681
+
682
+ .form-group label {
683
+ display: block;
684
+ margin-bottom: 5px;
685
+ font-weight: bold;
686
+ color: #495057;
687
+ }
688
+
689
+ .text-danger {
690
+ color: red;
691
+ font-weight: bolder;
692
+ }
693
+
694
+ .text-warning {
695
+ color: orange;
696
+ font-weight: bolder;
697
+ }
698
+
699
+ .text-success {
700
+ color: green;
701
+ font-weight: bolder;
702
+ }
703
+
704
+ .icon {
705
+ font-size: 20px;
706
+ }
707
+
708
+ .form-group input,
709
+ .form-group select,
710
+ .form-group textarea {
711
+ width: 100%;
712
+ padding: 8px;
713
+ border: 1px solid #ced4da;
714
+ border-radius: 4px;
715
+ }
716
+
717
+ .form-actions {
718
+ display: flex;
719
+ justify-content: flex-end;
720
+ gap: 10px;
721
+ margin-top: 20px;
722
+ }
723
+
724
+ .no-results {
725
+ text-align: center;
726
+ padding: 20px;
727
+ color: #6c757d;
728
+ }
729
+
730
+ .section-header {
731
+ display: flex;
732
+ justify-content: space-between;
733
+ align-items: center;
734
+ margin-bottom: 15px;
735
+ }
736
+
737
+ .vcard {
738
+ border-radius: 6px;
739
+ padding: 5px;
740
+ }
741
+
742
+ #content.active {
743
+ margin-left: var(--sidebar-width);
744
+ width: calc(100% - var(--sidebar-width));
745
+ }
746
+ </style>