versão 00

This commit is contained in:
Thaís Ferreira 2025-02-07 20:25:52 -03:00
parent cb4a5ab5ab
commit d5b3b23e49
3 changed files with 392 additions and 343 deletions

15
package-lock.json generated
View File

@ -15,7 +15,8 @@
"pinia": "^2.1.3", "pinia": "^2.1.3",
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-router": "^4.2.2", "vue-router": "^4.2.2",
"vuetify": "^3.4.0", "vue-the-mask": "^0.11.1",
"vuetify": "^3.7.11",
"vuex": "^4.1.0" "vuex": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
@ -11059,10 +11060,16 @@
"vue": "^3.2.0" "vue": "^3.2.0"
} }
}, },
"node_modules/vue-the-mask": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/vue-the-mask/-/vue-the-mask-0.11.1.tgz",
"integrity": "sha512-UquSfnSWejD0zAfcD+3jJ1chUAkOAyoxya9Lxh9acCRtrlmGcAIvd0cQYraWqKenbuZJUdum+S174atv2AuEHQ==",
"license": "MIT"
},
"node_modules/vuetify": { "node_modules/vuetify": {
"version": "3.7.8", "version": "3.7.11",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.7.8.tgz", "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.7.11.tgz",
"integrity": "sha512-67p7Ton7EikHKhzauAmpsV1HT/zA2DmG8hjcHmQrktGl1eBrRaQVE+idWLtbhO51jqpTsrKQK9+HuG0KRwIGYQ==", "integrity": "sha512-50Z2SNwPXbkGmve4CwxOs4sySZGgLwQYIDsKx+coSrfIBqz8IyXgxRFQdrvgoehIwUjGTNqaPZymuK5rMFkfHA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^12.20 || >=14.13" "node": "^12.20 || >=14.13"

View File

@ -21,7 +21,8 @@
"pinia": "^2.1.3", "pinia": "^2.1.3",
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-router": "^4.2.2", "vue-router": "^4.2.2",
"vuetify": "^3.4.0", "vue-the-mask": "^0.11.1",
"vuetify": "^3.7.11",
"vuex": "^4.1.0" "vuex": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,218 +1,166 @@
<!-- ProfilePage.vue -->
<template> <template>
<div class="profile-container"> <div class="profile-container">
<div class="profile-header"> <div class="profile-header">
<h1 class="profile-title">{{ $t('profile.title') }}</h1> <h1 class="profile-title">Perfil do Usuário</h1>
<p class="profile-subtitle">{{ $t('profile.subtitle') }}</p> <p class="profile-subtitle">Gerencie suas informações pessoais</p>
</div> </div>
<form <form @submit.prevent="saveProfile" class="profile-form">
@submit.prevent="submitProfile"
class="profile-form"
novalidate
aria-labelledby="profile-form-title"
>
<div class="profile-grid"> <div class="profile-grid">
<!-- Photo Section with Advanced Upload --> <!-- Coluna da Foto -->
<div class="photo-section"> <div class="photo-section">
<div <div class="profile-photo-container">
class="profile-photo-wrapper" <div class="photo-wrapper">
:class="{ 'has-error': photoError }" <img :src="profileData.photoUrl || defaultPhoto" alt="Foto de Perfil" class="profile-photo">
> <div class="photo-overlay">
<img <span class="photo-text">Alterar foto</span>
:src="previewPhotoUrl || defaultPhoto" </div>
:alt="$t('profile.photoAlt')"
class="profile-photo"
>
<div
class="photo-upload-overlay"
@click="openFileDialog"
@keydown.enter="openFileDialog"
tabindex="0"
role="button"
:aria-label="$t('profile.changePhoto')"
>
<i class="fas fa-camera"></i>
<span>{{ $t('profile.changePhoto') }}</span>
</div> </div>
<input <input
type="file" type="file"
@change="handlePhotoUpload"
accept="image/*"
ref="photoInput" ref="photoInput"
accept="image/jpeg,image/png,image/webp" class="photo-input"
@change="handlePhotoUpload"
class="hidden-file-input"
> >
<button type="button" @click="triggerPhotoUpload" class="upload-btn">
<i class="fas fa-camera"></i>
Alterar Foto
</button>
</div> </div>
<transition name="fade">
<div v-if="photoError" class="error-text">
{{ photoError }}
</div>
</transition>
</div> </div>
<!-- Profile Information Section --> <!-- Coluna das Informações -->
<div class="profile-information"> <div class="info-section">
<!-- Basic Information --> <!-- Seção de Informações Básicas -->
<div class="info-section"> <div class="section-title">
<h2 class="section-heading"> <i class="fas fa-user"></i>
<i class="fas fa-user-circle"></i> <h2 class="sub-title">Informações Básicas</h2>
{{ $t('profile.basicInfo') }} </div>
</h2>
<div class="form-grid"> <div class="form-row">
<div class="form-field"> <div class="form-group">
<label for="firstName">{{ $t('profile.firstName') }}</label> <label>ID</label>
<input <input type="text" v-model="profileData.id" readonly class="form-input readonly">
id="firstName"
v-model.trim="profileData.firstName"
type="text"
:class="{ 'is-invalid': v$.firstName.$error }"
@blur="v$.firstName.$touch()"
required
>
<div
v-if="v$.firstName.$error"
class="validation-error"
>
{{ $t('validation.requiredField') }}
</div>
</div>
<div class="form-field">
<label for="lastName">{{ $t('profile.lastName') }}</label>
<input
id="lastName"
v-model.trim="profileData.lastName"
type="text"
:class="{ 'is-invalid': v$.lastName.$error }"
@blur="v$.lastName.$touch()"
required
>
<div
v-if="v$.lastName.$error"
class="validation-error"
>
{{ $t('validation.requiredField') }}
</div>
</div>
</div> </div>
</div> </div>
<!-- Contact Information --> <div class="form-row">
<div class="info-section"> <div class="form-group">
<h2 class="section-heading"> <label>Nome</label>
<i class="fas fa-envelope"></i> <input
{{ $t('profile.contactInfo') }} type="text"
</h2> v-model="profileData.firstName"
required
class="form-input"
placeholder="Digite seu nome"
>
</div>
<div class="form-grid"> <div class="form-group">
<div class="form-field"> <label>Sobrenome</label>
<label for="email">{{ $t('profile.email') }}</label> <input
<input type="text"
id="email" v-model="profileData.lastName"
v-model.trim="profileData.email" required
type="email" class="form-input"
:class="{ 'is-invalid': v$.email.$error }" placeholder="Digite seu sobrenome"
@blur="v$.email.$touch()" >
required
>
<div
v-if="v$.email.$error"
class="validation-error"
>
{{ $t('validation.invalidEmail') }}
</div>
</div>
<div class="form-field">
<label for="phone">{{ $t('profile.phone') }}</label>
<input
id="phone"
v-model="profileData.phone"
type="tel"
v-mask="'(##) #####-####'"
placeholder="(00) 00000-0000"
>
</div>
</div> </div>
</div> </div>
<!-- Professional Information --> <!-- Seção de Contato -->
<div class="info-section"> <div class="section-title">
<h2 class="section-heading"> <i class="fas fa-address-card"></i>
<i class="fas fa-briefcase"></i> <h2>Informações de Contato</h2>
{{ $t('profile.professionalInfo') }} </div>
</h2>
<div class="form-grid"> <div class="form-row">
<div class="form-field"> <div class="form-group">
<label for="role">{{ $t('profile.role') }}</label> <label>Email</label>
<select <input
id="role" type="email"
v-model="profileData.role" v-model="profileData.email"
:class="{ 'is-invalid': v$.role.$error }" required
@blur="v$.role.$touch()" class="form-input"
required placeholder="seu@email.com"
> >
<option value="" disabled> </div>
{{ $t('profile.selectRole') }}
</option>
<option
v-for="role in roles"
:key="role.id"
:value="role.id"
>
{{ role.name }}
</option>
</select>
<div
v-if="v$.role.$error"
class="validation-error"
>
{{ $t('validation.requiredField') }}
</div>
</div>
<div class="form-field"> <div class="form-group">
<label for="group">{{ $t('profile.group') }}</label> <label>Telefone</label>
<select <input
id="group" type="tel"
v-model="profileData.group" v-model="profileData.phone"
> v-mask="'(##) #####-####'"
<option value="" disabled> class="form-input"
{{ $t('profile.selectGroup') }} placeholder="(00) 00000-0000"
</option> >
<option </div>
v-for="group in groups" </div>
:key="group.id"
:value="group.id" <!-- Seção Profissional -->
> <div class="section-title">
{{ group.name }} <i class="fas fa-briefcase"></i>
</option> <h2>Informações Profissionais</h2>
</select> </div>
</div>
<div class="form-row">
<div class="form-group">
<label>Cargo</label>
<select v-model="profileData.role" class="form-select">
<option value="" disabled>Selecione um cargo</option>
<option v-for="role in roles" :key="role.id" :value="role.id">
{{ role.name }}
</option>
</select>
</div>
<div class="form-group">
<label>Grupo</label>
<select v-model="profileData.group" class="form-select">
<option value="" disabled>Selecione um grupo</option>
<option v-for="group in groups" :key="group.id" :value="group.id">
{{ group.name }}
</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Permissão</label>
<select v-model="profileData.permission" class="form-select">
<option value="" disabled>Selecione uma permissão</option>
<option v-for="permission in permissions" :key="permission.id" :value="permission.id">
{{ permission.name }}
</option>
</select>
</div>
<div class="form-group">
<label>Turno</label>
<select v-model="profileData.shift" class="form-select">
<option value="" disabled>Selecione um turno</option>
<option value="morning">Manhã</option>
<option value="afternoon">Tarde</option>
<option value="night">Noite</option>
</select>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Action Buttons --> <!-- Botões de Ação -->
<div class="form-actions"> <div class="button-group">
<button <button type="button" @click="resetForm" class="cancel-btn">
type="button"
class="btn btn-secondary"
@click="resetForm"
:disabled="isSubmitting"
>
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
{{ $t('profile.cancel') }} Cancelar
</button> </button>
<button <button type="submit" class="save-btn">
type="submit"
class="btn btn-primary"
:disabled="isSubmitting || !isFormValid"
>
<i class="fas fa-save"></i> <i class="fas fa-save"></i>
{{ $t('profile.save') }} Salvar Alterações
</button> </button>
</div> </div>
</form> </form>
@ -220,28 +168,8 @@
</template> </template>
<script> <script>
import { ref } from 'vue'
import { useVuelidate } from '@vuelidate/core'
import { required, email } from '@vuelidate/validators'
import useProfileService from '@/services/profileService'
export default { export default {
name: 'ProfilePage', name: 'ProfilePage',
setup() {
const profileService = useProfileService()
const v$ = useVuelidate()
const previewPhotoUrl = ref(null)
const photoError = ref(null)
const isSubmitting = ref(false)
return {
v$,
previewPhotoUrl,
photoError,
isSubmitting,
profileService
}
},
data() { data() {
return { return {
defaultPhoto: '/path/to/default-avatar.png', defaultPhoto: '/path/to/default-avatar.png',
@ -253,208 +181,321 @@ export default {
email: '', email: '',
phone: '', phone: '',
role: '', role: '',
group: '' group: '',
permission: '',
shift: ''
}, },
roles: [ roles: [
{ id: 1, name: 'Employee' }, { id: 1, name: 'Funcionário' },
{ id: 2, name: 'Intern' }, { id: 2, name: 'Estagiário' },
{ id: 3, name: 'Manager' } { id: 3, name: 'Gerente' }
], ],
groups: [ groups: [
{ id: 1, name: 'Beta' }, { id: 1, name: 'Beta' },
{ id: 2, name: 'Alpha' }, { id: 2, name: 'Alfa' },
{ id: 3, name: 'Omega' } { id: 3, name: 'Omega' }
],
permissions: [
{ id: 1, name: 'Administrador' },
{ id: 2, name: 'Editor' },
{ id: 3, name: 'Visualizador' }
] ]
} }
}, },
computed: {
isFormValid() {
return !this.v$.$error &&
this.profileData.firstName &&
this.profileData.lastName &&
this.profileData.email &&
this.profileData.role
}
},
validations() {
return {
profileData: {
firstName: { required },
lastName: { required },
email: { required, email },
role: { required }
}
}
},
methods: { methods: {
async loadProfile() { async loadProfile() {
try { try {
const profile = await this.profileService.getProfile() const response = await fetch('/api/profile');
this.profileData = { ...profile } const data = await response.json();
this.previewPhotoUrl = profile.photoUrl this.profileData = { ...data };
} catch (error) { } catch (error) {
this.handleError(error) console.error('Erro ao carregar perfil:', error);
} }
}, },
openFileDialog() { triggerPhotoUpload() {
this.$refs.photoInput.click() this.$refs.photoInput.click();
}, },
handlePhotoUpload(event) { handlePhotoUpload(event) {
const file = event.target.files[0] const file = event.target.files[0];
this.photoError = null
if (file) { if (file) {
const maxSize = 5 * 1024 * 1024 // 5MB const reader = new FileReader();
const validTypes = ['image/jpeg', 'image/png', 'image/webp']
if (file.size > maxSize) {
this.photoError = this.$t('validation.photoSizeTooLarge')
return
}
if (!validTypes.includes(file.type)) {
this.photoError = this.$t('validation.invalidPhotoType')
return
}
const reader = new FileReader()
reader.onload = (e) => { reader.onload = (e) => {
this.previewPhotoUrl = e.target.result this.profileData.photoUrl = e.target.result;
this.profileData.photoUrl = e.target.result };
} reader.readAsDataURL(file);
reader.readAsDataURL(file)
} }
}, },
async submitProfile() { async saveProfile() {
this.v$.$touch()
if (this.v$.$invalid) return
this.isSubmitting = true
try { try {
await this.profileService.updateProfile(this.profileData) await fetch('/api/profile', {
this.$emit('profile-updated') method: 'PUT',
this.$toast.success(this.$t('profile.updateSuccess')) headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(this.profileData)
});
this.$emit('profile-updated');
this.showNotification('Perfil atualizado com sucesso!', 'success');
} catch (error) { } catch (error) {
this.handleError(error) console.error('Erro ao salvar perfil:', error);
} finally { this.showNotification('Erro ao salvar perfil. Tente novamente.', 'error');
this.isSubmitting = false
} }
}, },
resetForm() { resetForm() {
this.v$.$reset() this.loadProfile();
this.loadProfile()
this.previewPhotoUrl = this.profileData.photoUrl
}, },
handleError(error) { showNotification(message,type) {
console.error('Profile error:', error) // Implementar sistema de notificação de sua preferência
this.$toast.error( alert(message);
error.response?.data?.message ||
this.$t('profile.updateError')
)
} }
}, },
created() { created() {
this.loadProfile() this.loadProfile();
} }
} }
</script> </script>
<style scoped> <style scoped>
.profile-container { .profile-container {
max-width: 800px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 1rem; padding: 40px 20px;
}
.profile-header {
text-align: center;
margin-bottom: 40px;
}
.profile-title {
font-size: 2.5rem;
color: #2c3e50;
margin-bottom: 10px;
}
.profile-subtitle {
color: #7f8c8d;
font-size: 1.1rem;
}
.profile-form {
background: #fff;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
overflow: hidden;
} }
.profile-grid { .profile-grid {
display: grid; display: grid;
grid-template-columns: 1fr 2fr; grid-template-columns: 300px 1fr;
gap: 1rem; gap: 30px;
} }
.profile-photo-wrapper { .photo-section {
padding: 30px;
background: #f8f9fa;
border-right: 1px solid #eee;
}
.info-section {
padding: 30px;
}
.photo-wrapper {
position: relative; position: relative;
text-align: center; width: 200px;
height: 200px;
margin: 0 auto 20px;
border-radius: 50%;
overflow: hidden;
cursor: pointer;
} }
.profile-photo { .profile-photo {
width: 200px; width: 100%;
height: 200px; height: 100%;
object-fit: cover; object-fit: cover;
border-radius: 50%; transition: transform 0.3s ease;
} }
.photo-upload-overlay { .photo-overlay {
position: absolute; position: absolute;
bottom: 0; top: 0;
left: 50%; left: 0;
transform: translateX(-50%); width: 100%;
background: rgba(0,0,0,0.5); height: 100%;
color: white; background: rgba(0, 0, 0, 0.5);
padding: 0.5rem; display: flex;
border-radius: 0 0 100px 100px; align-items: center;
cursor: pointer; justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
} }
.hidden-file-input { .photo-text {
color: white;
font-size: 0.9rem;
font-weight: 500;
}
.photo-wrapper:hover .photo-overlay {
opacity: 1;
}
.photo-wrapper:hover .profile-photo {
transform: scale(1.1);
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
margin: 30px 0 20px;
padding-bottom: 10px;
border-bottom: 2px solid #e1e8ed;
}
.section-title i {
color: #3498db;
font-size: 1.2rem;
}
.section-title h2 {
font-size: 1.2rem;
color: #2c3e50;
margin: 0;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 0;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #34495e;
}
.form-input,
.form-select {
width: 100%;
padding: 12px;
border: 2px solid #e1e8ed;
border-radius: 8px;
font-size: 14px;
transition: all 0.3s ease;
}
.form-input:focus,
.form-select:focus {
border-color: #3498db;
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
outline: none;
}
.form-input.readonly {
background-color: #f8f9fa;
cursor: not-allowed;
color: #7f8c8d;
}
.photo-input {
display: none; display: none;
} }
.form-field { .upload-btn {
margin-bottom: 1rem;
}
.form-field label {
display: block;
margin-bottom: 0.5rem;
}
.form-field input,
.form-field select {
width: 100%; width: 100%;
padding: 0.5rem; padding: 12px;
border: 1px solid #ccc; background: #3498db;
border-radius: 4px; color: white;
}
.is-invalid {
border-color: #dc3545;
}
.validation-error {
color: #dc3545;
font-size: 0.8rem;
margin-top: 0.25rem;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 1rem;
}
.btn {
padding: 0.5rem 1rem;
border: none; border: none;
border-radius: 4px; border-radius: 8px;
font-weight: 500;
cursor: pointer; cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: background 0.3s ease;
} }
.btn-primary { .upload-btn:hover {
background-color: #007bff; background: #2980b9;
}
.button-group {
display: flex;
gap: 15px;
justify-content: flex-end;
padding: 30px;
background: #f8f9fa;
border-top: 1px solid #eee;
}
.save-btn,
.cancel-btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.3s ease;
}
.save-btn {
background: #2ecc71;
color: white; color: white;
} }
.btn-secondary { .save-btn:hover {
background-color: #6c757d; background: #27ae60;
transform: translateY(-1px);
}
.cancel-btn {
background: #e74c3c;
color: white; color: white;
} }
.btn:disabled { .cancel-btn:hover {
opacity: 0.5; background: #c0392b;
cursor: not-allowed; transform: translateY(-1px);
}
@media (max-width: 768px) {
.profile-grid {
grid-template-columns: 1fr;
}
.photo-section {
border-right: none;
border-bottom: 1px solid #eee;
}
.form-row {
grid-template-columns: 1fr;
}
.button-group {
flex-direction: column-reverse;
}
.save-btn,
.cancel-btn {
width: 100%;
justify-content: center;
}
} }
</style> </style>