@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.
- package/dist/profile-ui.es.js +9919 -0
- package/dist/profile-ui.es.js.map +1 -0
- package/dist/profile-ui.umd.js +95 -0
- package/dist/profile-ui.umd.js.map +1 -0
- package/dist/style.css +1 -0
- package/package.json +53 -0
- package/src/components/EducationModal.vue +126 -0
- package/src/components/ImageCropModal.vue +70 -0
- package/src/components/OtpModal.vue +166 -0
- package/src/components/ParentModal.vue +276 -0
- package/src/components/PersonalDataEditModal.vue +256 -0
- package/src/components/PreviewModal.vue +55 -0
- package/src/components/Profile.vue +171 -0
- package/src/components/ProfileEducation.vue +116 -0
- package/src/components/ProfileHeader.vue +182 -0
- package/src/components/ProfileParents.vue +173 -0
- package/src/components/ProfilePersonalData.vue +135 -0
- package/src/components/ProfileVerification.vue +118 -0
- package/src/components/SgFileUploader.vue +355 -0
|
@@ -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>
|