@muflih_kh/profile-ui 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,135 @@
1
+ <template>
2
+ <div class="card shadow-sm">
3
+ <div class="card-body">
4
+ <!-- Personal Data Section (View Only) -->
5
+ <div class="mb-4">
6
+ <div class="d-flex justify-content-between align-items-center mb-3">
7
+ <h4 class="fw-bold mb-0">Data Pribadi</h4>
8
+ <button class="btn btn-light rounded-circle" @click="showEditModal = true">
9
+ <i class="uil uil-edit"></i>
10
+ </button>
11
+ </div>
12
+ <div class="row g-3">
13
+ <div class="col-md-6">
14
+ <span class="text-muted">Nama Panggilan:</span>
15
+ <div class="fw-semibold">{{ user.nick_name || '-' }}</div>
16
+ </div>
17
+ <div class="col-md-6">
18
+ <span class="text-muted">Email:</span>
19
+ <div class="fw-semibold">{{ user.email || '-' }}</div>
20
+ </div>
21
+ <div class="col-md-6">
22
+ <span class="text-muted">NIK:</span>
23
+ <div class="fw-semibold">{{ user.national_id | nationalIdFormat }}</div>
24
+ </div>
25
+ <div class="col-md-6">
26
+ <span class="text-muted">No KK:</span>
27
+ <div class="fw-semibold">{{ user.family_national_id || '-' }}</div>
28
+ </div>
29
+ <div class="col-md-6">
30
+ <span class="text-muted">Kewarganegaraan:</span>
31
+ <div class="fw-semibold">{{ user.citizen?.label || '-' }}</div>
32
+ </div>
33
+ <div class="col-md-6">
34
+ <span class="text-muted">Negara:</span>
35
+ <div class="fw-semibold">{{ user.country?.label || '-' }}</div>
36
+ </div>
37
+ <div class="col-md-6">
38
+ <span class="text-muted">Phone:</span>
39
+ <div class="fw-semibold">{{ user.phone | formatPhone }}</div>
40
+ </div>
41
+ <div class="col-md-6">
42
+ <span class="text-muted">Tempat Lahir:</span>
43
+ <div class="fw-semibold">{{ user.place_of_birth || '-' }}</div>
44
+ </div>
45
+ <div class="col-md-6">
46
+ <span class="text-muted">Jenis Kelamin:</span>
47
+ <div class="fw-semibold">{{ user.sex | formatGender }}</div>
48
+ </div>
49
+ <div class="col-md-6">
50
+ <span class="text-muted">Tanggal Lahir:</span>
51
+ <div class="fw-semibold">{{ user.birth_date | formatDate }}</div>
52
+ </div>
53
+ <div class="col-md-6">
54
+ <span class="text-muted">KTP:</span>
55
+ <div v-if="user.national_card">
56
+ <a @click="$emit('show-preview', user.national_card?.url)" class="link">
57
+ <i class="uil uil-file-alt"></i> Lihat KTP
58
+ </a>
59
+ </div>
60
+ <div v-else class="fw-semibold">-</div>
61
+ </div>
62
+ <div class="col-md-12">
63
+ <span class="text-muted">Alamat:</span>
64
+ <div class="fw-semibold">{{ user.address || '-' }}</div>
65
+ </div>
66
+ <div class="col-md-12">
67
+ <span class="text-muted">Lokasi:</span>
68
+ <div class="fw-semibold">{{ user.location?.label || '-' }}</div>
69
+ </div>
70
+ </div>
71
+ </div>
72
+
73
+ <!-- Modal Edit Data Pribadi -->
74
+ <PersonalDataEditModal v-model="showEditModal" :user="user" :locations="locations" @save="handleSave"
75
+ @search-locations="handleSearchLocations" />
76
+ </div>
77
+ </div>
78
+ </template>
79
+
80
+ <script>
81
+ import PersonalDataEditModal from './PersonalDataEditModal.vue';
82
+
83
+ export default {
84
+ name: 'ProfilePersonalData',
85
+ components: {
86
+ PersonalDataEditModal
87
+ },
88
+ props: {
89
+ user: {
90
+ type: Object,
91
+ required: true
92
+ },
93
+ locations: {
94
+ type: Array,
95
+ default: () => []
96
+ }
97
+ },
98
+ data () {
99
+ return {
100
+ showEditModal: false
101
+ };
102
+ },
103
+ mounted () {
104
+ this.$root.$on('open-modal', ({ type }) => {
105
+ if (type === 'profile') {
106
+ this.showEditModal = true;
107
+ }
108
+ });
109
+ },
110
+ beforeDestroy () {
111
+ this.$root.$off('open-modal');
112
+ },
113
+ methods: {
114
+ handleSave (updatedData) {
115
+ this.$emit('update', updatedData);
116
+ this.showEditModal = false;
117
+ },
118
+ handleSearchLocations (search, loading) {
119
+ this.$emit('search-locations', search, loading);
120
+ }
121
+ }
122
+ };
123
+ </script>
124
+
125
+ <style scoped>
126
+ .link {
127
+ cursor: pointer;
128
+ color: #0d6efd;
129
+ text-decoration: none;
130
+ }
131
+
132
+ .link:hover {
133
+ text-decoration: underline;
134
+ }
135
+ </style>
@@ -0,0 +1,118 @@
1
+ <template>
2
+ <div class="card shadow-sm">
3
+ <div class="card-body border-top">
4
+ <div class="d-flex justify-content-between align-items-center mb-4">
5
+ <h3>Verifikasi Akun</h3>
6
+ </div>
7
+
8
+ <div class="row g-4">
9
+ <!-- Email Verification -->
10
+ <div class="col-12">
11
+ <div class="d-flex gap-3">
12
+ <div class="flex-shrink-0">
13
+ <div class="d-flex align-items-center justify-content-center"
14
+ :class="user.email_verified_at ? 'bg-success' : 'bg-secondary'"
15
+ style="width: 48px; height: 48px; border-radius: 8px;">
16
+ <i class="uil uil-envelope text-white fs-5"></i>
17
+ </div>
18
+ </div>
19
+ <div class="flex-fill">
20
+ <div class="d-flex justify-content-between align-items-start">
21
+ <div>
22
+ <h5 class="mb-1">Email</h5>
23
+ <p class="text-muted small mb-1">{{ user.email || '-' }}</p>
24
+ <span v-if="user.user_email && user.user_email.active" class="badge bg-success">
25
+ <i class="uil uil-check"></i> Terverifikasi
26
+ </span>
27
+ <span v-else class="badge bg-warning text-dark">
28
+ <i class="uil uil-exclamation-triangle"></i> Belum Terverifikasi
29
+ </span>
30
+ </div>
31
+ <button v-if="!user.user_email || !user.user_email.active" class="btn btn-primary btn-sm rounded-pill"
32
+ @click="openVerificationModal('email')">
33
+ Verifikasi
34
+ </button>
35
+ </div>
36
+ </div>
37
+ </div>
38
+ </div>
39
+
40
+ <!-- Phone Verification -->
41
+ <div class="col-12">
42
+ <div class="d-flex gap-3">
43
+ <div class="flex-shrink-0">
44
+ <div class="d-flex align-items-center justify-content-center"
45
+ :class="user.phone_verified_at ? 'bg-success' : 'bg-secondary'"
46
+ style="width: 48px; height: 48px; border-radius: 8px;">
47
+ <i class="uil uil-phone text-white fs-5"></i>
48
+ </div>
49
+ </div>
50
+ <div class="flex-fill">
51
+ <div class="d-flex justify-content-between align-items-start">
52
+ <div>
53
+ <h5 class="mb-1">Nomor Telepon</h5>
54
+ <p class="text-muted small mb-1">{{ user.phone | formatPhone }}</p>
55
+ <span v-if="user.phone_verified_at" class="badge bg-success">
56
+ <i class="uil uil-check"></i> Terverifikasi
57
+ </span>
58
+ <span v-else class="badge bg-warning text-dark">
59
+ <i class="uil uil-exclamation-triangle"></i> Belum Terverifikasi
60
+ </span>
61
+ </div>
62
+ <button v-if="!user.phone_verified_at" class="btn btn-primary btn-sm rounded-pill"
63
+ @click="openVerificationModal('phone')">
64
+ Verifikasi
65
+ </button>
66
+ </div>
67
+ </div>
68
+ </div>
69
+ </div>
70
+ </div>
71
+
72
+ <!-- Info Message -->
73
+ <div class="alert alert-info d-flex gap-3 mt-4" role="alert">
74
+ <i class="uil uil-info-circle flex-shrink-0"></i>
75
+ <div>
76
+ <p class="fw-semibold mb-1">Informasi Penting:</p>
77
+ <p class="mb-0 small">Verifikasi akun Anda untuk meningkatkan keamanan dan mengakses semua fitur aplikasi.</p>
78
+ </div>
79
+ </div>
80
+ </div>
81
+
82
+ <!-- OTP Modal -->
83
+ <OtpModal v-model="showOtpModal" :verification-type="verificationType" :email="user.email"
84
+ @verified="handleVerified" />
85
+ </div>
86
+ </template>
87
+
88
+ <script>
89
+ import OtpModal from './OtpModal.vue';
90
+
91
+ export default {
92
+ name: 'ProfileVerification',
93
+ components: {
94
+ OtpModal
95
+ },
96
+ props: {
97
+ user: {
98
+ type: Object,
99
+ required: true
100
+ }
101
+ },
102
+ data () {
103
+ return {
104
+ showOtpModal: false,
105
+ verificationType: ''
106
+ };
107
+ },
108
+ methods: {
109
+ openVerificationModal (type) {
110
+ this.verificationType = type;
111
+ this.showOtpModal = true;
112
+ },
113
+ handleVerified () {
114
+ this.$emit('verified');
115
+ }
116
+ }
117
+ };
118
+ </script>
@@ -0,0 +1,355 @@
1
+ <template>
2
+ <div class="mb-3">
3
+ <div v-if="editable">
4
+ <div class="label-row">
5
+ <label class="form-label fw-bold mb-0">{{ label }}</label>
6
+ <span v-if="templateUrl && previewType !== 'image'" class="template-link ms-2">
7
+ <a href="#" @click.prevent="downloadTemplate">
8
+ <div v-if="downloading" class="d-flex align-items-center">
9
+ <div class="spinner-border spinner-border-sm text-primary me-2" role="status">
10
+ <span class="visually-hidden">Loading...</span>
11
+ </div>
12
+ <span>Downloading...</span>
13
+ </div>
14
+ <div v-else class="d-flex align-items-center">
15
+ <svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"
16
+ class="me-1">
17
+ <path d="M10 3v10m0 0l-4-4m4 4l4-4M4 17h12" stroke="#2d8cf0" stroke-width="2" stroke-linecap="round"
18
+ stroke-linejoin="round" />
19
+ </svg>
20
+ Download Template
21
+ </div>
22
+ </a>
23
+ </span>
24
+ </div>
25
+ <input ref="fileInput" type="file" :accept="computedAccept" @change="onFileChange" style="display: none" />
26
+
27
+ <b-button variant="outline-secondary" @click="$refs.fileInput.click()" :disabled="uploading">
28
+ <i class="fa fa-upload me-1"></i>
29
+ {{ uploading ? 'Uploading...' : 'Upload' }}
30
+ </b-button>
31
+ </div>
32
+ <span v-if="error" class="error d-block mt-1">{{ errorMessage }}</span>
33
+
34
+ <div v-if="preview || (value && value.url)" class="mt-3 preview-container">
35
+ <template v-if="currentPreviewType === 'image'">
36
+ <img :src="currentPreviewUrl" :alt="label" class="preview-image" @click="openImageModal" />
37
+ </template>
38
+ <template v-else>
39
+ <a href="#" @click.prevent="openPreview" class="btn btn-sm btn-link p-0">
40
+ <svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" class="me-1">
41
+ <path d="M10 4.5c-5 0-8 5.5-8 5.5s3 5.5 8 5.5 8-5.5 8-5.5-3-5.5-8-5.5zm0 7.5a2 2 0 100-4 2 2 0 000 4z"
42
+ stroke="#888" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" />
43
+ </svg>
44
+ Lihat File{{ currentPreviewType === 'pdf' ? ' (PDF)' : '' }}
45
+ </a>
46
+ </template>
47
+ </div>
48
+
49
+ <!-- Modal Preview Image -->
50
+ <b-modal v-model="showImageModal" size="lg" centered hide-footer title="Preview Gambar">
51
+ <div class="text-center">
52
+ <img :src="currentPreviewUrl" :alt="label" class="modal-preview-image" />
53
+ </div>
54
+ </b-modal>
55
+ </div>
56
+ </template>
57
+
58
+ <script>
59
+ import { uploadFile as apiUploadFile, downloadTemplate as apiDownloadTemplate } from '../api/profileApi';
60
+
61
+ export default {
62
+ name: 'SgFileUploader',
63
+
64
+ props: {
65
+ label: {
66
+ type: String,
67
+ default: 'Upload File',
68
+ },
69
+ value: {
70
+ type: [Object, String, null],
71
+ default: null,
72
+ },
73
+ error: {
74
+ type: Boolean,
75
+ default: false,
76
+ },
77
+ errorMessage: {
78
+ type: String,
79
+ default: 'Wajib upload file',
80
+ },
81
+ endpoint: {
82
+ type: String,
83
+ default: '/api/v1/file/upload',
84
+ },
85
+ accept: {
86
+ type: String,
87
+ default: 'image/*',
88
+ },
89
+ templateUrl: {
90
+ type: String,
91
+ default: '',
92
+ },
93
+ templateLabel: {
94
+ type: String,
95
+ default: '',
96
+ },
97
+ templateType: {
98
+ type: String,
99
+ default: '',
100
+ },
101
+ wordOnly: {
102
+ type: Boolean,
103
+ default: false,
104
+ },
105
+ fileName: {
106
+ type: String,
107
+ default: null,
108
+ },
109
+ editable: {
110
+ type: Boolean,
111
+ default: true,
112
+ },
113
+ },
114
+
115
+ data() {
116
+ return {
117
+ preview: null,
118
+ previewType: null,
119
+ uploading: false,
120
+ downloading: false,
121
+ showImageModal: false,
122
+ };
123
+ },
124
+
125
+ computed: {
126
+ computedAccept() {
127
+ // Jika wordOnly aktif, hanya terima file Word
128
+ if (this.wordOnly) {
129
+ return '.doc,.docx,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document';
130
+ }
131
+ // Jika accept sudah diset, gunakan nilai tersebut
132
+ // Jika tidak, default ke image
133
+ return this.accept;
134
+ },
135
+
136
+ currentPreviewUrl() {
137
+ if (this.preview) return this.preview;
138
+ if (this.value && this.value.url) return this.value.url;
139
+ return null;
140
+ },
141
+
142
+ currentPreviewType() {
143
+ if (this.preview) return this.previewType;
144
+ if (this.value && this.value.url) return this.getPreviewType(this.value.url);
145
+ return null;
146
+ },
147
+ },
148
+
149
+ watch: {
150
+ value: {
151
+ immediate: true,
152
+ handler(newVal) {
153
+ this.setPreviewFromValue(newVal);
154
+ },
155
+ },
156
+ },
157
+
158
+ methods: {
159
+ getPreviewType(url) {
160
+ if (!url) return 'file';
161
+ if (url.match(/\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)$/i)) return 'image';
162
+ if (url.match(/\.pdf$/i)) return 'pdf';
163
+ return 'file';
164
+ },
165
+
166
+ setPreviewFromValue(val) {
167
+ if (val && typeof val === 'object' && val.url) {
168
+ this.preview = val.url;
169
+ this.previewType = this.getPreviewType(val.url);
170
+ } else if (typeof val === 'string' && val) {
171
+ this.preview = val;
172
+ this.previewType = this.getPreviewType(val);
173
+ } else {
174
+ this.preview = null;
175
+ this.previewType = null;
176
+ }
177
+ },
178
+
179
+ async onFileChange(e) {
180
+ const file = e.target.files[0];
181
+ if (!file) return;
182
+
183
+ // Validasi file jika wordOnly aktif
184
+ if (this.wordOnly) {
185
+ if (!this.isValidWordFile(file)) {
186
+ this.resetFile();
187
+ this.$toasted.error('Hanya file Word (.doc, .docx) yang diperbolehkan').goAway();
188
+ return;
189
+ }
190
+ }
191
+
192
+ // Validasi file image jika accept adalah image/*
193
+ if (this.accept === 'image/*' || this.accept.includes('image')) {
194
+ if (!this.isValidImageFile(file)) {
195
+ this.resetFile();
196
+ this.$toasted.error('Hanya file gambar yang diperbolehkan').goAway();
197
+ return;
198
+ }
199
+ }
200
+
201
+ // Set preview
202
+ this.setFilePreview(file);
203
+
204
+ // Upload file
205
+ await this.uploadFile(file);
206
+ },
207
+
208
+ isValidWordFile(file) {
209
+ const allowedTypes = [
210
+ 'application/msword',
211
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
212
+ ];
213
+ const allowedExtensions = ['.doc', '.docx'];
214
+ const fileExt = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
215
+
216
+ return allowedTypes.includes(file.type) || allowedExtensions.includes(fileExt);
217
+ },
218
+
219
+ isValidImageFile(file) {
220
+ const allowedTypes = [
221
+ 'image/jpeg',
222
+ 'image/jpg',
223
+ 'image/png',
224
+ 'image/gif',
225
+ 'image/webp',
226
+ 'image/svg+xml',
227
+ 'image/bmp',
228
+ ];
229
+ const allowedExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp'];
230
+ const fileExt = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
231
+
232
+ return allowedTypes.includes(file.type) || allowedExtensions.includes(fileExt);
233
+ },
234
+
235
+ setFilePreview(file) {
236
+ if (file.type.startsWith('image/')) {
237
+ this.previewType = 'image';
238
+ this.preview = URL.createObjectURL(file);
239
+ } else if (file.type === 'application/pdf') {
240
+ this.previewType = 'pdf';
241
+ this.preview = URL.createObjectURL(file);
242
+ } else {
243
+ this.previewType = 'file';
244
+ this.preview = null;
245
+ }
246
+ },
247
+
248
+ async uploadFile(file) {
249
+ const formData = new FormData();
250
+ formData.append('file', file);
251
+
252
+ this.uploading = true;
253
+
254
+ try {
255
+ const response = await apiUploadFile(this.endpoint, formData, this.wordOnly, this.fileName);
256
+ this.$emit('input', response.data?.data || null);
257
+ this.$emit('change', response.data?.data || null);
258
+ this.$toasted.success('File berhasil diunggah').goAway();
259
+ } catch (error) {
260
+ this.resetFile();
261
+ this.$toasted.error('Gagal mengunggah file').goAway();
262
+ console.error('Upload error:', error);
263
+ } finally {
264
+ this.uploading = false;
265
+ }
266
+ },
267
+
268
+ async downloadTemplate() {
269
+ if (!this.templateType) return;
270
+ this.downloading = true;
271
+ try {
272
+ const response = await apiDownloadTemplate(this.templateType);
273
+ const url = response.data.data;
274
+ window.open(url, '_blank', 'noopener,noreferrer');
275
+ } catch (error) {
276
+ this.$toasted.error('Gagal mengunduh template').goAway();
277
+ console.error('Download error:', error);
278
+ } finally {
279
+ this.downloading = false;
280
+ }
281
+ },
282
+
283
+ resetFile() {
284
+ this.$emit('input', null);
285
+ this.$emit('change', null);
286
+ this.$refs.fileInput.value = '';
287
+ },
288
+
289
+ openImageModal() {
290
+ this.showImageModal = true;
291
+ },
292
+
293
+ openPreview() {
294
+ // Untuk file non-image (PDF, Word, dll), buka di tab baru
295
+ window.open(this.currentPreviewUrl, '_blank', 'noopener,noreferrer');
296
+ },
297
+ },
298
+ };
299
+ </script>
300
+
301
+ <style scoped>
302
+ .label-row {
303
+ display: flex;
304
+ align-items: center;
305
+ justify-content: space-between;
306
+ margin-bottom: 8px;
307
+ }
308
+
309
+ .template-link a {
310
+ color: #2d8cf0;
311
+ text-decoration: none;
312
+ font-weight: 500;
313
+ font-size: 0.9em;
314
+ transition: color 0.2s;
315
+ }
316
+
317
+ .template-link a:hover {
318
+ color: #1c6dcf;
319
+ text-decoration: underline;
320
+ }
321
+
322
+ .error {
323
+ color: #d9534f;
324
+ font-size: 0.875em;
325
+ margin-top: 4px;
326
+ }
327
+
328
+ .preview-container {
329
+ padding: 12px;
330
+ background-color: #f8f9fa;
331
+ border-radius: 4px;
332
+ border: 1px solid #dee2e6;
333
+ }
334
+
335
+ .preview-image {
336
+ max-width: 200px;
337
+ max-height: 200px;
338
+ border-radius: 4px;
339
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
340
+ cursor: pointer;
341
+ transition: transform 0.2s, box-shadow 0.2s;
342
+ }
343
+
344
+ .preview-image:hover {
345
+ transform: scale(1.05);
346
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
347
+ }
348
+
349
+ .modal-preview-image {
350
+ max-width: 100%;
351
+ max-height: 70vh;
352
+ border-radius: 4px;
353
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
354
+ }
355
+ </style>