fix: resolve captain module lint and rubocop errors
This commit is contained in:
parent
269c36e4ea
commit
3c02c7a4c4
2
Gemfile
2
Gemfile
@ -270,3 +270,5 @@ group :development, :test do
|
|||||||
gem 'spring'
|
gem 'spring'
|
||||||
gem 'spring-watcher-listen'
|
gem 'spring-watcher-listen'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
gem "rqrcode", "~> 3.2"
|
||||||
|
|||||||
@ -188,6 +188,7 @@ GEM
|
|||||||
byebug (11.1.3)
|
byebug (11.1.3)
|
||||||
childprocess (5.1.0)
|
childprocess (5.1.0)
|
||||||
logger (~> 1.5)
|
logger (~> 1.5)
|
||||||
|
chunky_png (1.4.0)
|
||||||
climate_control (1.2.0)
|
climate_control (1.2.0)
|
||||||
coderay (1.1.3)
|
coderay (1.1.3)
|
||||||
commonmarker (0.23.10)
|
commonmarker (0.23.10)
|
||||||
@ -768,6 +769,10 @@ GEM
|
|||||||
nokogiri
|
nokogiri
|
||||||
rexml (3.4.4)
|
rexml (3.4.4)
|
||||||
rotp (6.3.0)
|
rotp (6.3.0)
|
||||||
|
rqrcode (3.2.0)
|
||||||
|
chunky_png (~> 1.0)
|
||||||
|
rqrcode_core (~> 2.0)
|
||||||
|
rqrcode_core (2.1.0)
|
||||||
rspec-core (3.13.0)
|
rspec-core (3.13.0)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-expectations (3.13.2)
|
rspec-expectations (3.13.2)
|
||||||
@ -1127,6 +1132,7 @@ DEPENDENCIES
|
|||||||
responders (>= 3.1.1)
|
responders (>= 3.1.1)
|
||||||
rest-client
|
rest-client
|
||||||
reverse_markdown
|
reverse_markdown
|
||||||
|
rqrcode (~> 3.2)
|
||||||
rspec-rails (>= 6.1.5)
|
rspec-rails (>= 6.1.5)
|
||||||
rspec_junit_formatter
|
rspec_junit_formatter
|
||||||
rubocop
|
rubocop
|
||||||
|
|||||||
@ -0,0 +1,69 @@
|
|||||||
|
class Public::Api::V1::Captain::InterWebhooksController < ApplicationController
|
||||||
|
skip_before_action :verify_authenticity_token
|
||||||
|
|
||||||
|
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
||||||
|
def create
|
||||||
|
payload = JSON.parse(request.body.read)
|
||||||
|
pix_data = payload['pix']
|
||||||
|
|
||||||
|
if pix_data.blank?
|
||||||
|
render json: { message: 'Ignored: No pix data' }, status: :ok
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
txid = pix_data['txid']
|
||||||
|
e2eid = pix_data['endToEndId']
|
||||||
|
|
||||||
|
# Idempotency Check
|
||||||
|
existing_charge = ::Captain::PixCharge.find_by(e2eid: e2eid)
|
||||||
|
if existing_charge
|
||||||
|
render json: { message: 'Already processed' }, status: :ok
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find Charge
|
||||||
|
charge = ::Captain::PixCharge.find_by(txid: txid)
|
||||||
|
unless charge
|
||||||
|
render json: { message: 'Charge not found' }, status: :ok # Return 200 to satisfy Inter retry policy
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Update Charge
|
||||||
|
charge.update!(
|
||||||
|
status: 'paid',
|
||||||
|
e2eid: e2eid,
|
||||||
|
paid_at: Time.current,
|
||||||
|
raw_webhook_payload: payload
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update Reservation
|
||||||
|
charge.reservation.update!(payment_status: 'paid')
|
||||||
|
|
||||||
|
# Notify Chat
|
||||||
|
notify_chat(charge.reservation)
|
||||||
|
|
||||||
|
render json: { message: 'Received' }, status: :ok
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Webhook Error: #{e.message}"
|
||||||
|
render json: { error: e.message }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def notify_chat(reservation)
|
||||||
|
return unless reservation.conversation_id
|
||||||
|
|
||||||
|
conversation = Conversation.find(reservation.conversation_id)
|
||||||
|
|
||||||
|
Messages::CreateService.new(
|
||||||
|
conversation: conversation,
|
||||||
|
params: {
|
||||||
|
content: "✅ Pagamento confirmado! Sua reserva ##{reservation.id} na unidade #{reservation.captain_unit.name} está garantida.",
|
||||||
|
message_type: :outgoing
|
||||||
|
}
|
||||||
|
).perform
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Failed to notify chat: #{e.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
945
app/javascript/captain_booking/App.vue
Normal file
945
app/javascript/captain_booking/App.vue
Normal file
@ -0,0 +1,945 @@
|
|||||||
|
<script setup>
|
||||||
|
/* eslint-disable no-console, no-alert, vue/no-bare-strings-in-template, vue/no-static-inline-styles */
|
||||||
|
import { ref, reactive, onMounted, onUnmounted, computed, watch } from 'vue';
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
const view = ref('form'); // form, payment, success
|
||||||
|
const isDataLoading = ref(true);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const isCopied = ref(false);
|
||||||
|
const submissionStatus = ref({
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
success: false,
|
||||||
|
reservationId: null, // Track ID for polling
|
||||||
|
pix: {
|
||||||
|
copyPasteCode: '',
|
||||||
|
qrCodeValue: '',
|
||||||
|
},
|
||||||
|
scarcityText: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const appConfig = reactive({
|
||||||
|
title: 'Reserva Premium',
|
||||||
|
subtitle: 'Hotel 1001 Noites Prime',
|
||||||
|
});
|
||||||
|
|
||||||
|
const formData = reactive({
|
||||||
|
nome: '',
|
||||||
|
checkInDateTime: '',
|
||||||
|
telefone: '',
|
||||||
|
email: '',
|
||||||
|
cpf: '',
|
||||||
|
observacao: '',
|
||||||
|
selectedBrand: '',
|
||||||
|
selectedUnit: '',
|
||||||
|
selectedCategory: '',
|
||||||
|
stayDuration: '',
|
||||||
|
selectedExtras: [], // Future Phase
|
||||||
|
});
|
||||||
|
|
||||||
|
// Options State
|
||||||
|
const brands = ref([]);
|
||||||
|
// const units = ref([]); // Removed unused var
|
||||||
|
const pricings = ref([]);
|
||||||
|
const extras = ref([]);
|
||||||
|
|
||||||
|
const unitOptions = ref([]);
|
||||||
|
const categoryOptions = ref([]);
|
||||||
|
const durationOptions = ref([]);
|
||||||
|
|
||||||
|
// Price State
|
||||||
|
const calculatedPrice = ref(null);
|
||||||
|
const isPriceLoading = ref(false);
|
||||||
|
|
||||||
|
// --- API Methods ---
|
||||||
|
const fetchMasterData = async () => {
|
||||||
|
isDataLoading.value = true;
|
||||||
|
try {
|
||||||
|
const pathParts = window.location.pathname.split('/');
|
||||||
|
const accountIdIndex = pathParts.indexOf('accounts') + 1;
|
||||||
|
const accountId = pathParts[accountIdIndex];
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/public/api/v1/captain/master_data?account_id=${accountId}`
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error('Failed to load data');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
brands.value = data.brands;
|
||||||
|
pricings.value = data.pricings;
|
||||||
|
extras.value = data.extras;
|
||||||
|
} catch (error) {
|
||||||
|
// console.error("Master Data Error:", error);
|
||||||
|
} finally {
|
||||||
|
isDataLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Watchers & Computed ---
|
||||||
|
|
||||||
|
// When Brand changes
|
||||||
|
const handleBrandChange = () => {
|
||||||
|
formData.selectedUnit = '';
|
||||||
|
formData.selectedCategory = '';
|
||||||
|
formData.stayDuration = '';
|
||||||
|
|
||||||
|
const brand = brands.value.find(b => String(b.id) === formData.selectedBrand);
|
||||||
|
if (brand) {
|
||||||
|
unitOptions.value = brand.units.map(u => ({
|
||||||
|
value: String(u.id),
|
||||||
|
label: u.name,
|
||||||
|
}));
|
||||||
|
durationOptions.value = brand.stay_durations.map(d => ({
|
||||||
|
value: d,
|
||||||
|
label: d,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
unitOptions.value = [];
|
||||||
|
durationOptions.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// When Unit changes
|
||||||
|
const handleUnitChange = () => {
|
||||||
|
formData.selectedCategory = '';
|
||||||
|
const brand = brands.value.find(b => String(b.id) === formData.selectedBrand);
|
||||||
|
const unit = brand?.units.find(u => String(u.id) === formData.selectedUnit);
|
||||||
|
|
||||||
|
if (
|
||||||
|
unit &&
|
||||||
|
unit.visible_suite_categories &&
|
||||||
|
unit.visible_suite_categories.length > 0
|
||||||
|
) {
|
||||||
|
categoryOptions.value = unit.visible_suite_categories.map(c => ({
|
||||||
|
value: c,
|
||||||
|
label: c,
|
||||||
|
}));
|
||||||
|
} else if (
|
||||||
|
brand &&
|
||||||
|
brand.suite_categories &&
|
||||||
|
brand.suite_categories.length > 0
|
||||||
|
) {
|
||||||
|
categoryOptions.value = brand.suite_categories.map(c => ({
|
||||||
|
value: c,
|
||||||
|
label: c,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
categoryOptions.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Price Calculation Logic
|
||||||
|
const suiteImage = computed(() => {
|
||||||
|
if (!formData.selectedBrand || !formData.selectedCategory) return null;
|
||||||
|
const brand = brands.value.find(b => String(b.id) === formData.selectedBrand);
|
||||||
|
if (!brand) return null;
|
||||||
|
|
||||||
|
const images = brand.suiteImages || brand.suite_images || {};
|
||||||
|
return images[formData.selectedCategory] || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Price Calculation Logic
|
||||||
|
const calculatePrice = () => {
|
||||||
|
if (
|
||||||
|
!formData.selectedBrand ||
|
||||||
|
!formData.selectedCategory ||
|
||||||
|
!formData.stayDuration ||
|
||||||
|
!formData.checkInDateTime
|
||||||
|
) {
|
||||||
|
calculatedPrice.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isPriceLoading.value = true;
|
||||||
|
// Simulate async price lookup or local calc
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const brandId = parseInt(formData.selectedBrand, 10);
|
||||||
|
const date = new Date(formData.checkInDateTime);
|
||||||
|
const dayIndex = date.getDay(); // 0 = Sunday, 6 = Saturday
|
||||||
|
|
||||||
|
const daysMap = [
|
||||||
|
'DOMINGO',
|
||||||
|
'SEGUNDA',
|
||||||
|
'TERÇA',
|
||||||
|
'QUARTA',
|
||||||
|
'QUINTA',
|
||||||
|
'SEXTA',
|
||||||
|
'SÁBADO',
|
||||||
|
];
|
||||||
|
const currentDayName = daysMap[dayIndex];
|
||||||
|
|
||||||
|
// Find matching pricing row
|
||||||
|
const priceRow = pricings.value.find(p => {
|
||||||
|
if (p.captain_brand_id !== brandId) return false;
|
||||||
|
if (p.suite_category !== formData.selectedCategory) return false;
|
||||||
|
if (p.duration !== formData.stayDuration) return false;
|
||||||
|
|
||||||
|
// Check day range
|
||||||
|
const range = (p.day_range || p.dayRange || '').toUpperCase();
|
||||||
|
|
||||||
|
if (range.includes(' A ')) {
|
||||||
|
if (range === 'SEGUNDA A QUARTA') {
|
||||||
|
return ['SEGUNDA', 'TERÇA', 'QUARTA'].includes(currentDayName);
|
||||||
|
}
|
||||||
|
if (range === 'QUINTA A DOMINGO') {
|
||||||
|
return ['QUINTA', 'SEXTA', 'SÁBADO', 'DOMINGO'].includes(
|
||||||
|
currentDayName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = range.split(',').map(d => d.trim());
|
||||||
|
return days.includes(currentDayName);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (priceRow) {
|
||||||
|
calculatedPrice.value = parseFloat(priceRow.price);
|
||||||
|
} else {
|
||||||
|
calculatedPrice.value = null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// console.error(e);
|
||||||
|
} finally {
|
||||||
|
isPriceLoading.value = false;
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [
|
||||||
|
formData.selectedBrand,
|
||||||
|
formData.selectedCategory,
|
||||||
|
formData.stayDuration,
|
||||||
|
formData.checkInDateTime,
|
||||||
|
],
|
||||||
|
calculatePrice
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Actions ---
|
||||||
|
|
||||||
|
const calculateCheckOut = (startStr, durationStr) => {
|
||||||
|
const start = new Date(startStr);
|
||||||
|
let hoursToAdd = 4;
|
||||||
|
if (durationStr?.toUpperCase().includes('PERNOITE')) {
|
||||||
|
hoursToAdd = 12;
|
||||||
|
}
|
||||||
|
const end = new Date(start.getTime() + hoursToAdd * 60 * 60 * 1000);
|
||||||
|
return end.toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = val => {
|
||||||
|
return new Intl.NumberFormat('pt-BR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'BRL',
|
||||||
|
}).format(val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyPix = () => {
|
||||||
|
if (submissionStatus.value?.pix?.copyPasteCode) {
|
||||||
|
navigator.clipboard.writeText(submissionStatus.value.pix.copyPasteCode);
|
||||||
|
isCopied.value = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
isCopied.value = false;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetForm = () => {
|
||||||
|
view.value = 'form';
|
||||||
|
formData.nome = '';
|
||||||
|
formData.telefone = '';
|
||||||
|
formData.cpf = '';
|
||||||
|
formData.email = '';
|
||||||
|
formData.checkInDateTime = '';
|
||||||
|
formData.observacao = '';
|
||||||
|
formData.selectedBrand = '';
|
||||||
|
formData.selectedUnit = '';
|
||||||
|
formData.selectedCategory = '';
|
||||||
|
formData.stayDuration = '';
|
||||||
|
submissionStatus.value = null;
|
||||||
|
calculatedPrice.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateScarcityMessage = () => {
|
||||||
|
const messages = [
|
||||||
|
'🔥 Resta apenas 1 suíte disponível para esta data!',
|
||||||
|
'⚡ Alta demanda! 2 pessoas estão vendo esta suíte agora.',
|
||||||
|
'💎 Última chance! O hotel está quase lotado.',
|
||||||
|
'⏳ Segure sua vaga! Restam apenas 2 suítes.',
|
||||||
|
'👀 Muita procura para esta data. Garanta sua reserva!',
|
||||||
|
];
|
||||||
|
return messages[Math.floor(Math.random() * messages.length)];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Functions defined before use
|
||||||
|
const triggerConfetti = () => {
|
||||||
|
import('canvas-confetti')
|
||||||
|
.then(confetti => {
|
||||||
|
confetti.default({
|
||||||
|
particleCount: 150,
|
||||||
|
spread: 70,
|
||||||
|
origin: { y: 0.6 },
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
let pollingInterval = null;
|
||||||
|
const checkPaymentStatus = async () => {
|
||||||
|
if (!submissionStatus.value?.reservationId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/public/api/v1/captain/reservations/${submissionStatus.value.reservationId}/status`
|
||||||
|
);
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.payment_status === 'paid') {
|
||||||
|
clearInterval(pollingInterval);
|
||||||
|
view.value = 'success';
|
||||||
|
triggerConfetti();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// console.error('Error polling status:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startPolling = () => {
|
||||||
|
if (pollingInterval) clearInterval(pollingInterval);
|
||||||
|
pollingInterval = setInterval(checkPaymentStatus, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!calculatedPrice.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
submissionStatus.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pathParts = window.location.pathname.split('/');
|
||||||
|
const accountId = pathParts[pathParts.indexOf('accounts') + 1];
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
brand_id: formData.selectedBrand,
|
||||||
|
unit_id: formData.selectedUnit,
|
||||||
|
contact_name: formData.nome,
|
||||||
|
phone_number: formData.telefone.replace(/\D/g, ''),
|
||||||
|
email: formData.email,
|
||||||
|
cpf: formData.cpf.replace(/\D/g, ''),
|
||||||
|
check_in_at: formData.checkInDateTime,
|
||||||
|
duration_minutes: 0,
|
||||||
|
check_out_at: calculateCheckOut(
|
||||||
|
formData.checkInDateTime,
|
||||||
|
formData.stayDuration
|
||||||
|
),
|
||||||
|
total_amount: calculatedPrice.value,
|
||||||
|
metadata: {
|
||||||
|
category: formData.selectedCategory,
|
||||||
|
stay_duration: formData.stayDuration,
|
||||||
|
observacao: formData.observacao,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/public/api/v1/captain/reservations?account_id=${accountId}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
if (result.metadata?.pix) {
|
||||||
|
submissionStatus.value = {
|
||||||
|
message:
|
||||||
|
'Sua reserva foi iniciada! Realize o pagamento via Pix para confirmar.',
|
||||||
|
type: 'success',
|
||||||
|
reservationId: result.reservation_id,
|
||||||
|
pix: result.metadata.pix,
|
||||||
|
scarcityText: generateScarcityMessage(),
|
||||||
|
};
|
||||||
|
view.value = 'payment';
|
||||||
|
// Trigger polling
|
||||||
|
startPolling();
|
||||||
|
} else {
|
||||||
|
submissionStatus.value = {
|
||||||
|
message: 'Reserva criada, mas falha ao gerar Pix. Entre em contato.',
|
||||||
|
type: 'error',
|
||||||
|
};
|
||||||
|
view.value = 'payment';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Falha ao criar reserva');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// console.error('Submit Error:', error);
|
||||||
|
// alert('Erro: ' + error.message);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (pollingInterval) clearInterval(pollingInterval);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
fetchMasterData();
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatPhone = event => {
|
||||||
|
let value = event.target.value.replace(/\D/g, '');
|
||||||
|
if (value.length > 11) value = value.slice(0, 11);
|
||||||
|
|
||||||
|
if (value.length > 10) {
|
||||||
|
value = value.replace(/^(\d{2})(\d{5})(\d{4}).*/, '($1) $2-$3');
|
||||||
|
} else if (value.length > 5) {
|
||||||
|
value = value.replace(/^(\d{2})(\d{4})(\d{0,4}).*/, '($1) $2-$3');
|
||||||
|
} else if (value.length > 2) {
|
||||||
|
value = value.replace(/^(\d{2})(\d{0,5}).*/, '($1) $2');
|
||||||
|
}
|
||||||
|
|
||||||
|
formData.telefone = value;
|
||||||
|
event.target.value = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCPF = event => {
|
||||||
|
let value = event.target.value.replace(/\D/g, '');
|
||||||
|
if (value.length > 11) value = value.slice(0, 11);
|
||||||
|
|
||||||
|
value = value.replace(/(\d{3})(\d)/, '$1.$2');
|
||||||
|
value = value.replace(/(\d{3})(\d)/, '$1.$2');
|
||||||
|
value = value.replace(/(\d{3})(\d{1,2})$/, '$1-$2');
|
||||||
|
|
||||||
|
formData.cpf = value;
|
||||||
|
event.target.value = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidEmail = email => {
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFormValid = computed(() => {
|
||||||
|
return (
|
||||||
|
formData.nome.trim().length > 0 &&
|
||||||
|
formData.telefone.length >= 14 && // (XX) XXXXX-XXXX is 15 chars, or (XX) XXXX-XXXX is 14
|
||||||
|
formData.cpf.length === 14 && // XXX.XXX.XXX-XX
|
||||||
|
isValidEmail(formData.email) &&
|
||||||
|
formData.checkInDateTime &&
|
||||||
|
formData.selectedBrand &&
|
||||||
|
formData.selectedUnit &&
|
||||||
|
formData.selectedCategory &&
|
||||||
|
formData.stayDuration
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewTitle = computed(() => {
|
||||||
|
if (view.value === 'payment') return 'Pagamento Seguro';
|
||||||
|
if (view.value === 'success') return 'Reserva Confirmada';
|
||||||
|
return appConfig.title;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template, vue/no-static-inline-styles -->
|
||||||
|
<div
|
||||||
|
class="min-h-screen py-6 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center bg-fixed"
|
||||||
|
style="background: linear-gradient(135deg, #0a1a2f 0%, #1b3b5f 100%)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-full max-w-3xl bg-white rounded-[2rem] shadow-2xl overflow-hidden border border-white/10 relative"
|
||||||
|
>
|
||||||
|
<!-- Decorative Top Accent -->
|
||||||
|
<div
|
||||||
|
class="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-[#1B3B5F] to-[#1E90FF]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="p-6 sm:p-12">
|
||||||
|
<div
|
||||||
|
class="flex justify-between items-start mb-10 border-b border-[#1B3B5F]/10 pb-6"
|
||||||
|
>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h1
|
||||||
|
class="text-2xl sm:text-3xl font-extrabold text-[#1B3B5F] tracking-tight"
|
||||||
|
>
|
||||||
|
{{ viewTitle }}
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
v-if="view === 'form'"
|
||||||
|
class="text-[#9CA3AF] text-sm font-medium"
|
||||||
|
>
|
||||||
|
{{ appConfig.subtitle }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LOADING STATE -->
|
||||||
|
<div
|
||||||
|
v-if="isDataLoading"
|
||||||
|
class="text-center py-20 flex flex-col items-center justify-center space-y-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-8 h-8 border-4 border-[#1E90FF] border-t-transparent rounded-full animate-spin"
|
||||||
|
/>
|
||||||
|
<p class="text-[#9CA3AF] font-medium animate-pulse">
|
||||||
|
Carregando dados...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SUCCESS VIEW -->
|
||||||
|
<div
|
||||||
|
v-else-if="view === 'success'"
|
||||||
|
class="text-center space-y-6 p-10 bg-[#F8FAFC] border border-[#1B3B5F]/10 rounded-3xl shadow-inner animate-fade-in"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx-auto w-24 h-24 bg-green-100 rounded-full flex items-center justify-center mb-6 shadow-md"
|
||||||
|
>
|
||||||
|
<!-- Success Icon -->
|
||||||
|
<svg
|
||||||
|
class="h-12 w-12 text-green-600"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-3xl font-extrabold text-[#1B3B5F]">
|
||||||
|
Pagamento Confirmado!
|
||||||
|
</h2>
|
||||||
|
<p class="text-[#9CA3AF] text-lg">
|
||||||
|
Sua reserva está 100% garantida.<br />Enviamos os detalhes para o
|
||||||
|
seu e-mail.
|
||||||
|
</p>
|
||||||
|
<div class="pt-6">
|
||||||
|
<button
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||||
|
@click="handleResetForm"
|
||||||
|
>
|
||||||
|
Fazer Nova Reserva
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PAYMENT VIEW -->
|
||||||
|
<div
|
||||||
|
v-else-if="view === 'payment'"
|
||||||
|
class="text-center space-y-6 animate-fade-in"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="p-4 bg-[#F8FAFC] rounded-2xl border border-[#1B3B5F]/10 mb-6"
|
||||||
|
>
|
||||||
|
<p class="text-[#1B3B5F] font-medium">
|
||||||
|
{{ submissionStatus?.message }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scarcity Trigger -->
|
||||||
|
<div
|
||||||
|
v-if="submissionStatus?.scarcityText"
|
||||||
|
class="animate-pulse bg-red-50 border border-red-100 p-3 rounded-xl"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="text-red-600 font-bold text-sm flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<i class="i-lucide-flame text-lg" />
|
||||||
|
{{ submissionStatus.scarcityText }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WhatsApp Warning -->
|
||||||
|
<div class="text-center px-4">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Após o pagamento, você receberá a confirmação imediatamente no seu
|
||||||
|
<strong class="text-green-600">WhatsApp</strong>
|
||||||
|
<span class="font-mono text-xs">({{ formData.telefone }})</span>.
|
||||||
|
<br />
|
||||||
|
<span class="text-xs text-gray-400 block mt-1"
|
||||||
|
>Certifique-se que o número informado está correto.</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-4 pb-2 text-left">
|
||||||
|
<label
|
||||||
|
class="block text-xs font-bold text-[#1B3B5F] uppercase tracking-wide mb-2"
|
||||||
|
>Código Pix Copia e Cola</label
|
||||||
|
>
|
||||||
|
<div class="relative group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readonly
|
||||||
|
:value="submissionStatus?.pix?.copyPasteCode || ''"
|
||||||
|
class="w-full bg-[#F8FAFC] border-[1.5px] border-[#1B3B5F]/20 rounded-xl p-4 pr-28 text-sm text-[#1B3B5F] font-mono focus:outline-none focus:border-[#1E90FF]"
|
||||||
|
/>
|
||||||
|
<div class="absolute right-2 top-1/2 -translate-y-1/2">
|
||||||
|
<button
|
||||||
|
class="px-3 py-1 bg-gray-200 hover:bg-gray-300 rounded text-sm font-medium transition-colors"
|
||||||
|
@click="handleCopyPix"
|
||||||
|
>
|
||||||
|
{{ isCopied ? 'Copiado!' : 'Copiar' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- QR Code Image -->
|
||||||
|
<div
|
||||||
|
v-if="submissionStatus?.pix?.qrCodeValue"
|
||||||
|
class="flex justify-center mt-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="p-4 bg-white rounded-xl shadow-lg border border-gray-100"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-gray-500 mb-2">
|
||||||
|
Escaneie o QR Code no app do seu banco
|
||||||
|
</p>
|
||||||
|
<img
|
||||||
|
:src="`https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=${encodeURIComponent(
|
||||||
|
submissionStatus.pix.copyPasteCode
|
||||||
|
)}`"
|
||||||
|
alt="QR Code do PIX"
|
||||||
|
class="w-48 h-48 object-contain mix-blend-multiply"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-4 border-t border-[#1B3B5F]/10">
|
||||||
|
<button
|
||||||
|
class="w-full text-[#9CA3AF] hover:text-[#1B3B5F] py-2"
|
||||||
|
@click="handleResetForm"
|
||||||
|
>
|
||||||
|
Cancelar e Voltar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FORM VIEW -->
|
||||||
|
<form v-else class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
|
<div
|
||||||
|
class="bg-[#F8FAFC] p-6 rounded-2xl border border-[#1B3B5F]/10 mb-8 shadow-sm space-y-4"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
class="text-[#1B3B5F] font-bold text-sm uppercase tracking-wider mb-4 border-b border-[#1B3B5F]/10 pb-2"
|
||||||
|
>
|
||||||
|
Detalhes da Estadia
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Brand Selection -->
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="brand"
|
||||||
|
class="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Marca <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="brand"
|
||||||
|
v-model="formData.selectedBrand"
|
||||||
|
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md shadow-sm"
|
||||||
|
@change="handleBrandChange"
|
||||||
|
>
|
||||||
|
<option value="" disabled>Selecione a marca</option>
|
||||||
|
<option
|
||||||
|
v-for="brand in brands"
|
||||||
|
:key="brand.id"
|
||||||
|
:value="String(brand.id)"
|
||||||
|
>
|
||||||
|
{{ brand.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unit and Duration -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="unit"
|
||||||
|
class="block text-sm font-medium text-gray-700"
|
||||||
|
>Unidade <span class="text-red-500">*</span></label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="unit"
|
||||||
|
v-model="formData.selectedUnit"
|
||||||
|
:disabled="!unitOptions.length"
|
||||||
|
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md shadow-sm disabled:bg-gray-100"
|
||||||
|
@change="handleUnitChange"
|
||||||
|
>
|
||||||
|
<option value="" disabled>Selecione a unidade</option>
|
||||||
|
<option
|
||||||
|
v-for="unit in unitOptions"
|
||||||
|
:key="unit.value"
|
||||||
|
:value="unit.value"
|
||||||
|
>
|
||||||
|
{{ unit.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="duration"
|
||||||
|
class="block text-sm font-medium text-gray-700"
|
||||||
|
>Permanência <span class="text-red-500">*</span></label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="duration"
|
||||||
|
v-model="formData.stayDuration"
|
||||||
|
:disabled="!durationOptions.length"
|
||||||
|
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md shadow-sm disabled:bg-gray-100"
|
||||||
|
>
|
||||||
|
<option value="" disabled>Selecione o tempo</option>
|
||||||
|
<option
|
||||||
|
v-for="opt in durationOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:value="opt.value"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category -->
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="category"
|
||||||
|
class="block text-sm font-medium text-gray-700"
|
||||||
|
>Categoria da Suíte <span class="text-red-500">*</span></label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="category"
|
||||||
|
v-model="formData.selectedCategory"
|
||||||
|
:disabled="!categoryOptions.length"
|
||||||
|
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md shadow-sm disabled:bg-gray-100"
|
||||||
|
>
|
||||||
|
<option value="" disabled>Selecione a categoria</option>
|
||||||
|
<option
|
||||||
|
v-for="opt in categoryOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:value="opt.value"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Checkin Date -->
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="checkin"
|
||||||
|
class="block text-sm font-medium text-gray-700"
|
||||||
|
>Data e Horário do Check-in
|
||||||
|
<span class="text-red-500">*</span></label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="checkin"
|
||||||
|
v-model="formData.checkInDateTime"
|
||||||
|
type="datetime-local"
|
||||||
|
required
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photos View -->
|
||||||
|
<div v-if="suiteImage" class="my-6 animate-fade-in">
|
||||||
|
<div
|
||||||
|
class="rounded-2xl overflow-hidden shadow-lg border border-[#1B3B5F]/10"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="suiteImage"
|
||||||
|
alt="Suite Preview"
|
||||||
|
class="w-full h-64 object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Price Display -->
|
||||||
|
<div class="my-8">
|
||||||
|
<div
|
||||||
|
v-if="isPriceLoading"
|
||||||
|
class="text-center p-6 bg-[#F8FAFC] rounded-2xl border border-[#1B3B5F]/10"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-6 h-6 border-2 border-[#1E90FF] border-t-transparent rounded-full animate-spin mx-auto mb-2"
|
||||||
|
/>
|
||||||
|
<p class="text-sm text-[#9CA3AF]">Calculando valor...</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="calculatedPrice !== null"
|
||||||
|
class="relative overflow-hidden p-6 bg-[#F8FAFC] border-[1.5px] border-[#1E90FF]/20 rounded-2xl animate-fade-in shadow-lg shadow-[#1E90FF]/5"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute top-0 right-0 bg-[#1E90FF] text-white text-[10px] font-bold px-3 py-1 rounded-bl-lg"
|
||||||
|
>
|
||||||
|
PREÇO ESTIMADO
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
class="flex justify-between items-center text-sm text-[#1B3B5F]"
|
||||||
|
>
|
||||||
|
<span class="font-medium">Valor Total da Reserva</span>
|
||||||
|
<span class="font-bold text-lg">{{
|
||||||
|
formatCurrency(calculatedPrice)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex justify-between items-center text-sm text-[#9CA3AF]"
|
||||||
|
>
|
||||||
|
<span>Pagar no check-in (50%)</span>
|
||||||
|
<span class="font-medium">{{
|
||||||
|
formatCurrency(calculatedPrice / 2)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="pt-4 border-t border-[#1B3B5F]/10 flex justify-between items-end"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
class="text-xs font-bold text-[#1E90FF] uppercase tracking-wider mb-1"
|
||||||
|
>
|
||||||
|
Entrada via Pix (50%)
|
||||||
|
</p>
|
||||||
|
<p class="text-[#9CA3AF] text-xs">
|
||||||
|
Necessário para confirmar
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="text-3xl font-extrabold text-[#1B3B5F] tracking-tight"
|
||||||
|
>
|
||||||
|
{{ formatCurrency(calculatedPrice / 2) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Details -->
|
||||||
|
<div
|
||||||
|
class="bg-[#F8FAFC] p-6 rounded-2xl border border-[#1B3B5F]/10 mb-8 shadow-sm space-y-4"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
class="text-[#1B3B5F] font-bold text-sm uppercase tracking-wider mb-4 border-b border-[#1B3B5F]/10 pb-2"
|
||||||
|
>
|
||||||
|
Seus Dados
|
||||||
|
</h3>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700"
|
||||||
|
>Nome Completo <span class="text-red-500">*</span></label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="formData.nome"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Seu nome completo"
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-xl shadow-sm focus:ring-[#1E90FF] focus:border-[#1E90FF] text-base py-3 px-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700"
|
||||||
|
>Telefone / WhatsApp
|
||||||
|
<span class="text-red-500">*</span></label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:value="formData.telefone"
|
||||||
|
type="tel"
|
||||||
|
required
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-xl shadow-sm focus:ring-[#1E90FF] focus:border-[#1E90FF] text-base py-3 px-4"
|
||||||
|
placeholder="(99) 99999-9999"
|
||||||
|
maxlength="15"
|
||||||
|
@input="formatPhone"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-400 mt-1 ml-1">
|
||||||
|
Formato: (99) 99999-9999
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700"
|
||||||
|
>CPF <span class="text-red-500">*</span></label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:value="formData.cpf"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-xl shadow-sm focus:ring-[#1E90FF] focus:border-[#1E90FF] text-base py-3 px-4"
|
||||||
|
placeholder="000.000.000-00"
|
||||||
|
maxlength="14"
|
||||||
|
@input="formatCPF"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700"
|
||||||
|
>E-mail <span class="text-red-500">*</span></label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="formData.email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-xl shadow-sm focus:ring-[#1E90FF] focus:border-[#1E90FF] text-base py-3 px-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700"
|
||||||
|
>Observação</label
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
v-model="formData.observacao"
|
||||||
|
rows="2"
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-xl shadow-sm focus:ring-[#1E90FF] focus:border-[#1E90FF] text-base py-3 px-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="!isFormValid || isLoading || isDataLoading"
|
||||||
|
class="w-full flex justify-center py-4 px-6 border border-transparent rounded-xl shadow-xl shadow-[#1E90FF]/30 hover:shadow-[#1E90FF]/50 text-lg font-bold text-white bg-[#1E90FF] hover:bg-[#1B3B5F] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1E90FF] disabled:bg-gray-300 disabled:shadow-none disabled:cursor-not-allowed transition-all duration-300 uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
{{ isLoading ? 'Processando...' : 'Confirmar e Pagar Reserva' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="text-center text-xs font-medium text-white/40 mt-8">
|
||||||
|
© {{ new Date().getFullYear() }} {{ appConfig.title }} •
|
||||||
|
Experiência Exclusiva
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Global overrides to ensure background covers the entire page */
|
||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, #0a1a2f 0%, #1b3b5f 100%) fixed !important;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Specific overrides if needed */
|
||||||
|
</style>
|
||||||
14
app/javascript/captain_booking/assets/main.css
Normal file
14
app/javascript/captain_booking/assets/main.css
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fadeIn 0.5s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
18
app/javascript/dashboard/api/captain/asset.js
Normal file
18
app/javascript/dashboard/api/captain/asset.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class CaptainAsset extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('captain/assets', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
get({ page = 1 } = {}) {
|
||||||
|
return axios.get(this.url, {
|
||||||
|
params: {
|
||||||
|
page,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new CaptainAsset();
|
||||||
@ -41,6 +41,10 @@ class CaptainAssistant extends ApiClient {
|
|||||||
updateTool(assistantId, toolKey, config) {
|
updateTool(assistantId, toolKey, config) {
|
||||||
return axios.patch(`${this.url}/${assistantId}/tools/${toolKey}`, config);
|
return axios.patch(`${this.url}/${assistantId}/tools/${toolKey}`, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testWebhook(assistantId) {
|
||||||
|
return axios.post(`${this.url}/${assistantId}/test_webhook`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new CaptainAssistant();
|
export default new CaptainAssistant();
|
||||||
|
|||||||
19
app/javascript/dashboard/api/captain/inboxAutomations.js
Normal file
19
app/javascript/dashboard/api/captain/inboxAutomations.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class CaptainInboxAutomations extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('captain/inbox_automations', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
get({ page = 1, inboxId } = {}) {
|
||||||
|
return axios.get(this.url, {
|
||||||
|
params: {
|
||||||
|
page,
|
||||||
|
inbox_id: inboxId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new CaptainInboxAutomations();
|
||||||
@ -21,6 +21,13 @@ class CaptainInboxes extends ApiClient {
|
|||||||
const { assistantId, inboxId } = params;
|
const { assistantId, inboxId } = params;
|
||||||
return axios.delete(`${this.url}/${assistantId}/inboxes/${inboxId}`);
|
return axios.delete(`${this.url}/${assistantId}/inboxes/${inboxId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
update(inboxId, params = {}) {
|
||||||
|
const { assistantId, always_use_reminder_tool } = params;
|
||||||
|
return axios.patch(`${this.url}/${assistantId}/inboxes/${inboxId}`, {
|
||||||
|
inbox: { always_use_reminder_tool },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new CaptainInboxes();
|
export default new CaptainInboxes();
|
||||||
|
|||||||
21
app/javascript/dashboard/api/captain/reminderSettings.js
Normal file
21
app/javascript/dashboard/api/captain/reminderSettings.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class CaptainReminderSettings extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('inboxes', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
get(inboxId) {
|
||||||
|
return axios.get(`${this.url}/${inboxId}/captain/reminder_settings`);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(inboxId, payload) {
|
||||||
|
return axios.patch(
|
||||||
|
`${this.url}/${inboxId}/captain/reminder_settings`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new CaptainReminderSettings();
|
||||||
23
app/javascript/dashboard/api/captain/reminders.js
Normal file
23
app/javascript/dashboard/api/captain/reminders.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class CaptainReminders extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('captain/reminders', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
get({ page = 1, conversationId, inboxId, contactId, from, to } = {}) {
|
||||||
|
return axios.get(this.url, {
|
||||||
|
params: {
|
||||||
|
page,
|
||||||
|
conversation_id: conversationId,
|
||||||
|
inbox_id: inboxId,
|
||||||
|
contact_id: contactId,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new CaptainReminders();
|
||||||
34
app/javascript/dashboard/api/captain/reservations.js
Normal file
34
app/javascript/dashboard/api/captain/reservations.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class CaptainReservations extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('captain/reservations', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
get({
|
||||||
|
page = 1,
|
||||||
|
conversationId,
|
||||||
|
inboxId,
|
||||||
|
contactId,
|
||||||
|
unit_id,
|
||||||
|
status,
|
||||||
|
date_from,
|
||||||
|
date_to,
|
||||||
|
} = {}) {
|
||||||
|
return axios.get(this.url, {
|
||||||
|
params: {
|
||||||
|
page,
|
||||||
|
conversation_id: conversationId,
|
||||||
|
inbox_id: inboxId,
|
||||||
|
contact_id: contactId,
|
||||||
|
unit_id,
|
||||||
|
status,
|
||||||
|
date_from,
|
||||||
|
date_to,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new CaptainReservations();
|
||||||
17
app/javascript/dashboard/api/captain/units.js
Normal file
17
app/javascript/dashboard/api/captain/units.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import ApiClient from '../../api/ApiClient';
|
||||||
|
|
||||||
|
class UnitsAPI extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('captain/units', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
get(params) {
|
||||||
|
return window.axios.get(this.url, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id, data) {
|
||||||
|
return window.axios.patch(`${this.url}/${id}`, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new UnitsAPI();
|
||||||
@ -6,6 +6,7 @@ import Settings from './Settings.vue';
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<Story
|
<Story
|
||||||
title="Captain/AnimatingImg/AnimatingImg"
|
title="Captain/AnimatingImg/AnimatingImg"
|
||||||
:layout="{ type: 'grid', width: '300px' }"
|
:layout="{ type: 'grid', width: '300px' }"
|
||||||
|
|||||||
@ -9,6 +9,7 @@ const toggle = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<div
|
<div
|
||||||
class="svg-wrapper relative"
|
class="svg-wrapper relative"
|
||||||
role="button"
|
role="button"
|
||||||
|
|||||||
@ -9,6 +9,7 @@ const toggle = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<div
|
<div
|
||||||
class="svg-wrapper relative"
|
class="svg-wrapper relative"
|
||||||
:class="{ paused }"
|
:class="{ paused }"
|
||||||
|
|||||||
@ -9,6 +9,7 @@ const toggle = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<div
|
<div
|
||||||
class="svg-wrapper relative"
|
class="svg-wrapper relative"
|
||||||
:class="{ paused }"
|
:class="{ paused }"
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<div class="svg-wrapper relative" tabindex="0">
|
<div class="svg-wrapper relative" tabindex="0">
|
||||||
<div class="absolute z-0 flex-shrink-0">
|
<div class="absolute z-0 flex-shrink-0">
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@ -30,6 +30,10 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
headerDescription: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
backUrl: {
|
backUrl: {
|
||||||
type: [String, Object],
|
type: [String, Object],
|
||||||
default: '',
|
default: '',
|
||||||
@ -115,6 +119,7 @@ const handleCreateAssistant = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
||||||
<header class="sticky top-0 z-10 px-6">
|
<header class="sticky top-0 z-10 px-6">
|
||||||
<div class="w-full max-w-[60rem] mx-auto">
|
<div class="w-full max-w-[60rem] mx-auto">
|
||||||
@ -200,7 +205,11 @@ const handleCreateAssistant = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<slot name="subHeader" />
|
<slot name="subHeader">
|
||||||
|
<p v-if="headerDescription" class="mt-2 text-sm text-n-slate-11">
|
||||||
|
{{ headerDescription }}
|
||||||
|
</p>
|
||||||
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="flex-1 px-6 overflow-y-auto">
|
<main class="flex-1 px-6 overflow-y-auto">
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import AddNewRulesDialog from './AddNewRulesDialog.vue';
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<Story
|
<Story
|
||||||
title="Captain/Assistant/AddNewRulesDialog"
|
title="Captain/Assistant/AddNewRulesDialog"
|
||||||
:layout="{ type: 'grid', width: '800px' }"
|
:layout="{ type: 'grid', width: '800px' }"
|
||||||
|
|||||||
@ -45,6 +45,7 @@ const onClickCancel = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<div
|
<div
|
||||||
v-on-click-outside="() => togglePopover(false)"
|
v-on-click-outside="() => togglePopover(false)"
|
||||||
class="inline-flex relative"
|
class="inline-flex relative"
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import AddNewRulesInput from './AddNewRulesInput.vue';
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<Story
|
<Story
|
||||||
title="Captain/Assistant/AddNewRulesInput"
|
title="Captain/Assistant/AddNewRulesInput"
|
||||||
:layout="{ type: 'grid', width: '800px' }"
|
:layout="{ type: 'grid', width: '800px' }"
|
||||||
|
|||||||
@ -29,6 +29,7 @@ const onClickAdd = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<div
|
<div
|
||||||
class="flex py-3 ltr:pl-3 h-16 rtl:pr-3 ltr:pr-4 rtl:pl-4 items-center gap-3 rounded-xl bg-n-solid-2 outline-1 outline outline-n-container"
|
class="flex py-3 ltr:pl-3 h-16 rtl:pr-3 ltr:pr-4 rtl:pl-4 items-center gap-3 rounded-xl bg-n-solid-2 outline-1 outline outline-n-container"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -87,6 +87,7 @@ const onClickCancel = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<div
|
<div
|
||||||
v-on-click-outside="() => togglePopover(false)"
|
v-on-click-outside="() => togglePopover(false)"
|
||||||
class="inline-flex relative"
|
class="inline-flex relative"
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { assistantsList } from 'dashboard/components-next/captain/pageComponents
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<Story
|
<Story
|
||||||
title="Captain/Assistant/AssistantCard"
|
title="Captain/Assistant/AssistantCard"
|
||||||
:layout="{ type: 'grid', width: '700px' }"
|
:layout="{ type: 'grid', width: '700px' }"
|
||||||
|
|||||||
@ -74,6 +74,7 @@ const handleAction = ({ action, value }) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<CardLayout>
|
<CardLayout>
|
||||||
<div class="flex justify-between w-full gap-1">
|
<div class="flex justify-between w-full gap-1">
|
||||||
<h6
|
<h6
|
||||||
|
|||||||
@ -75,6 +75,7 @@ const sendMessage = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<div
|
<div
|
||||||
class="flex flex-col h-full rounded-xl border py-6 border-n-weak text-n-slate-11"
|
class="flex flex-col h-full rounded-xl border py-6 border-n-weak text-n-slate-11"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -52,6 +52,7 @@ const bulkCheckboxState = computed({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<transition
|
<transition
|
||||||
name="slide-fade"
|
name="slide-fade"
|
||||||
enter-active-class="transition-all duration-300 ease-out"
|
enter-active-class="transition-all duration-300 ease-out"
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { documentsList } from 'dashboard/components-next/captain/pageComponents/
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<Story
|
<Story
|
||||||
title="Captain/Assistant/DocumentCard"
|
title="Captain/Assistant/DocumentCard"
|
||||||
:layout="{ type: 'grid', width: '700px' }"
|
:layout="{ type: 'grid', width: '700px' }"
|
||||||
|
|||||||
@ -79,6 +79,7 @@ const handleAction = ({ action, value }) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<CardLayout>
|
<CardLayout>
|
||||||
<div class="flex gap-1 justify-between w-full">
|
<div class="flex gap-1 justify-between w-full">
|
||||||
<span class="text-base text-n-slate-12 line-clamp-1">
|
<span class="text-base text-n-slate-12 line-clamp-1">
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { inboxes } from 'dashboard/components-next/captain/pageComponents/emptyS
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<Story
|
<Story
|
||||||
title="Captain/Assistant/InboxCard"
|
title="Captain/Assistant/InboxCard"
|
||||||
:layout="{ type: 'grid', width: '700px' }"
|
:layout="{ type: 'grid', width: '700px' }"
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useToggle } from '@vueuse/core';
|
import { useToggle } from '@vueuse/core';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useStore } from 'dashboard/composables/store';
|
||||||
|
|
||||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||||
import Policy from 'dashboard/components/policy.vue';
|
import Policy from 'dashboard/components/policy.vue';
|
||||||
import { INBOX_TYPES, getInboxIconByType } from 'dashboard/helper/inbox';
|
import { INBOX_TYPES, getInboxIconByType } from 'dashboard/helper/inbox';
|
||||||
|
|
||||||
@ -18,13 +21,29 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
assistantId: {
|
||||||
|
type: [Number, String],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['action']);
|
const emit = defineEmits(['action']);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||||
|
const isUpdating = ref(false);
|
||||||
|
const reminderToolEnabled = ref(
|
||||||
|
props.inbox?.captain_inbox?.always_use_reminder_tool || false
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.inbox?.captain_inbox?.always_use_reminder_tool,
|
||||||
|
value => {
|
||||||
|
reminderToolEnabled.value = value || false;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const inboxName = computed(() => {
|
const inboxName = computed(() => {
|
||||||
const inbox = props.inbox;
|
const inbox = props.inbox;
|
||||||
@ -66,9 +85,30 @@ const handleAction = ({ action, value }) => {
|
|||||||
toggleDropdown(false);
|
toggleDropdown(false);
|
||||||
emit('action', { action, value, id: props.id });
|
emit('action', { action, value, id: props.id });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleReminderTool = async value => {
|
||||||
|
if (isUpdating.value) return;
|
||||||
|
isUpdating.value = true;
|
||||||
|
reminderToolEnabled.value = value;
|
||||||
|
try {
|
||||||
|
await store.dispatch('captainInboxes/update', {
|
||||||
|
id: props.id,
|
||||||
|
assistantId: props.assistantId,
|
||||||
|
always_use_reminder_tool: value,
|
||||||
|
});
|
||||||
|
useAlert(t('CAPTAIN.INBOXES.REMINDER_TOOL.SUCCESS'));
|
||||||
|
} catch (error) {
|
||||||
|
reminderToolEnabled.value =
|
||||||
|
props.inbox?.captain_inbox?.always_use_reminder_tool || false;
|
||||||
|
useAlert(t('CAPTAIN.INBOXES.REMINDER_TOOL.ERROR'));
|
||||||
|
} finally {
|
||||||
|
isUpdating.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<CardLayout>
|
<CardLayout>
|
||||||
<div class="flex justify-between w-full gap-1">
|
<div class="flex justify-between w-full gap-1">
|
||||||
<span
|
<span
|
||||||
@ -99,5 +139,16 @@ const handleAction = ({ action, value }) => {
|
|||||||
</Policy>
|
</Policy>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center justify-between mt-3 text-xs text-n-slate-11">
|
||||||
|
<span>{{ t('CAPTAIN.INBOXES.REMINDER_TOOL.LABEL') }}</span>
|
||||||
|
<Switch
|
||||||
|
v-model="reminderToolEnabled"
|
||||||
|
:disabled="isUpdating"
|
||||||
|
@update:model-value="toggleReminderTool"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-n-slate-10">
|
||||||
|
{{ t('CAPTAIN.INBOXES.REMINDER_TOOL.HELP') }}
|
||||||
|
</p>
|
||||||
</CardLayout>
|
</CardLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -49,6 +49,7 @@ watch(() => props.messages.length, scrollToBottom);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<div
|
<div
|
||||||
ref="messageContainer"
|
ref="messageContainer"
|
||||||
class="flex-1 overflow-y-auto mb-4 px-6 space-y-6"
|
class="flex-1 overflow-y-auto mb-4 px-6 space-y-6"
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { responsesList } from 'dashboard/components-next/captain/pageComponents/
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<Story
|
<Story
|
||||||
title="Captain/Assistant/ResponseCard"
|
title="Captain/Assistant/ResponseCard"
|
||||||
:layout="{ type: 'grid', width: '700px' }"
|
:layout="{ type: 'grid', width: '700px' }"
|
||||||
|
|||||||
@ -125,6 +125,7 @@ const handleDocumentableClick = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<CardLayout
|
<CardLayout
|
||||||
selectable
|
selectable
|
||||||
class="relative"
|
class="relative"
|
||||||
|
|||||||
@ -9,6 +9,7 @@ const sampleRules = [
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<Story
|
<Story
|
||||||
title="Captain/Assistant/RuleCard"
|
title="Captain/Assistant/RuleCard"
|
||||||
:layout="{ type: 'grid', width: '800px' }"
|
:layout="{ type: 'grid', width: '800px' }"
|
||||||
|
|||||||
@ -59,6 +59,7 @@ const saveEdit = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<CardLayout
|
<CardLayout
|
||||||
selectable
|
selectable
|
||||||
class="relative [&>div]:!py-5 [&>div]:ltr:!pr-4 [&>div]:rtl:!pl-4"
|
class="relative [&>div]:!py-5 [&>div]:ltr:!pr-4 [&>div]:rtl:!pl-4"
|
||||||
|
|||||||
@ -22,6 +22,7 @@ const sampleScenarios = [
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<Story
|
<Story
|
||||||
title="Captain/Assistant/ScenariosCard"
|
title="Captain/Assistant/ScenariosCard"
|
||||||
:layout="{ type: 'grid', width: '800px' }"
|
:layout="{ type: 'grid', width: '800px' }"
|
||||||
|
|||||||
@ -137,6 +137,7 @@ const renderInstruction = instruction => () =>
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<CardLayout
|
<CardLayout
|
||||||
selectable
|
selectable
|
||||||
class="relative [&>div]:!py-4"
|
class="relative [&>div]:!py-4"
|
||||||
|
|||||||
@ -19,6 +19,7 @@ const guidelinesExample = [
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<Story
|
<Story
|
||||||
title="Captain/Assistant/SuggestedRules"
|
title="Captain/Assistant/SuggestedRules"
|
||||||
:layout="{ type: 'grid', width: '800px' }"
|
:layout="{ type: 'grid', width: '800px' }"
|
||||||
|
|||||||
@ -27,6 +27,7 @@ const onClickClose = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-start self-stretch rounded-xl w-full overflow-hidden border border-dashed border-n-strong"
|
class="flex flex-col items-start self-stretch rounded-xl w-full overflow-hidden border border-dashed border-n-strong"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -24,6 +24,7 @@ const selectedIndex = ref(0);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<Story
|
<Story
|
||||||
title="Captain/Assistant/ToolsDropdown"
|
title="Captain/Assistant/ToolsDropdown"
|
||||||
:layout="{ type: 'grid', width: '600px' }"
|
:layout="{ type: 'grid', width: '600px' }"
|
||||||
|
|||||||
@ -35,6 +35,7 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<div
|
<div
|
||||||
ref="toolsDropdownRef"
|
ref="toolsDropdownRef"
|
||||||
class="w-[22.5rem] p-2 flex flex-col gap-1 z-50 absolute rounded-xl bg-n-alpha-3 shadow outline outline-1 outline-n-weak backdrop-blur-[50px] max-h-[20rem] overflow-y-auto"
|
class="w-[22.5rem] p-2 flex flex-col gap-1 z-50 absolute rounded-xl bg-n-alpha-3 shadow outline outline-1 outline-n-weak backdrop-blur-[50px] max-h-[20rem] overflow-y-auto"
|
||||||
|
|||||||
@ -48,6 +48,7 @@ defineExpose({ dialogRef: bulkDeleteDialogRef });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<Dialog
|
<Dialog
|
||||||
ref="bulkDeleteDialogRef"
|
ref="bulkDeleteDialogRef"
|
||||||
type="alert"
|
type="alert"
|
||||||
|
|||||||
@ -54,6 +54,7 @@ defineExpose({ dialogRef: deleteDialogRef });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<Dialog
|
<Dialog
|
||||||
ref="deleteDialogRef"
|
ref="deleteDialogRef"
|
||||||
type="alert"
|
type="alert"
|
||||||
|
|||||||
@ -26,6 +26,7 @@ const openBilling = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<div
|
<div
|
||||||
class="w-full max-w-[60rem] mx-auto h-full max-h-[448px] grid place-content-center"
|
class="w-full max-w-[60rem] mx-auto h-full max-h-[448px] grid place-content-center"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -0,0 +1,111 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||||
|
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
fileUrl: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['action']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const shortcut = computed(() => `{{ media.${props.name} }}`);
|
||||||
|
|
||||||
|
const copyShortcut = async () => {
|
||||||
|
await copyTextToClipboard(shortcut.value);
|
||||||
|
useAlert(t('CAPTAIN.ASSETS.COPY_SHORTCUT.SUCCESS'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyUrl = async () => {
|
||||||
|
if (!props.fileUrl) return;
|
||||||
|
await copyTextToClipboard(props.fileUrl);
|
||||||
|
useAlert(t('CAPTAIN.ASSETS.COPY_URL.SUCCESS'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
emit('action', { action: 'delete', id: props.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
emit('action', { action: 'edit', id: props.id });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
|
<div class="flex gap-4 p-4 border border-n-weak rounded-md bg-n-alpha-2">
|
||||||
|
<div class="w-20 h-20 rounded-md overflow-hidden bg-n-slate-3">
|
||||||
|
<img
|
||||||
|
v-if="fileUrl"
|
||||||
|
:src="fileUrl"
|
||||||
|
:alt="name"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div v-else class="w-full h-full flex items-center justify-center">
|
||||||
|
<i class="text-2xl i-ph-image text-n-slate-11" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 flex flex-col gap-2">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-base font-semibold text-n-slate-12">
|
||||||
|
{{ name }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-n-slate-11">
|
||||||
|
{{ t('CAPTAIN.ASSETS.SHORTCUT_LABEL') }}:
|
||||||
|
<span class="font-mono">{{ shortcut }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-pencil"
|
||||||
|
variant="ghost"
|
||||||
|
color="slate"
|
||||||
|
@click="handleEdit"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
variant="ghost"
|
||||||
|
color="slate"
|
||||||
|
@click="handleDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
sm
|
||||||
|
icon="i-lucide-copy"
|
||||||
|
variant="ghost"
|
||||||
|
color="slate"
|
||||||
|
:label="t('CAPTAIN.ASSETS.COPY_SHORTCUT.LABEL')"
|
||||||
|
@click="copyShortcut"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="fileUrl"
|
||||||
|
sm
|
||||||
|
icon="i-lucide-link"
|
||||||
|
variant="ghost"
|
||||||
|
color="slate"
|
||||||
|
:label="t('CAPTAIN.ASSETS.COPY_URL.LABEL')"
|
||||||
|
@click="copyUrl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,204 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactive, computed, ref, nextTick, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
|
import { required, requiredIf } from '@vuelidate/validators';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
default: 'create',
|
||||||
|
validator: value => ['create', 'edit'].includes(value),
|
||||||
|
},
|
||||||
|
asset: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['submit', 'cancel']);
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
|
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const formState = {
|
||||||
|
uiFlags: useMapGetter('captainAssets/getUIFlags'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
name: '',
|
||||||
|
file: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = reactive({ ...initialState });
|
||||||
|
const fileInputRef = ref(null);
|
||||||
|
const isEditMode = computed(() => props.mode === 'edit');
|
||||||
|
|
||||||
|
const validationRules = {
|
||||||
|
name: { required },
|
||||||
|
file: { required: requiredIf(() => !isEditMode.value) },
|
||||||
|
};
|
||||||
|
|
||||||
|
const v$ = useVuelidate(validationRules, state);
|
||||||
|
|
||||||
|
const isLoading = computed(() =>
|
||||||
|
isEditMode.value
|
||||||
|
? formState.uiFlags.value.updatingItem
|
||||||
|
: formState.uiFlags.value.creatingItem
|
||||||
|
);
|
||||||
|
|
||||||
|
const getErrorMessage = field => {
|
||||||
|
return v$.value[field].$error
|
||||||
|
? t(`CAPTAIN.ASSETS.FORM.${field.toUpperCase()}.ERROR`)
|
||||||
|
: '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formErrors = computed(() => ({
|
||||||
|
name: getErrorMessage('name'),
|
||||||
|
file: getErrorMessage('file'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleCancel = () => emit('cancel');
|
||||||
|
|
||||||
|
const openFileDialog = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
fileInputRef.value?.click();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = event => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||||
|
useAlert(t('CAPTAIN.ASSETS.FORM.FILE.INVALID_TYPE'));
|
||||||
|
event.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
useAlert(t('CAPTAIN.ASSETS.FORM.FILE.TOO_LARGE'));
|
||||||
|
event.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.file = file;
|
||||||
|
if (!state.name) {
|
||||||
|
state.name = file.name.replace(/\.[^/.]+$/, '');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateStateFromAsset = asset => {
|
||||||
|
state.name = asset?.name || '';
|
||||||
|
state.file = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const prepareAssetDetails = () => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('asset[name]', state.name);
|
||||||
|
if (state.file) {
|
||||||
|
formData.append('asset[file]', state.file);
|
||||||
|
}
|
||||||
|
return formData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const isFormValid = await v$.value.$validate();
|
||||||
|
if (!isFormValid) return;
|
||||||
|
|
||||||
|
emit('submit', prepareAssetDetails());
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.asset,
|
||||||
|
asset => {
|
||||||
|
if (isEditMode.value && asset) {
|
||||||
|
updateStateFromAsset(asset);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
|
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||||
|
<Input
|
||||||
|
v-model="state.name"
|
||||||
|
:label="t('CAPTAIN.ASSETS.FORM.NAME.LABEL')"
|
||||||
|
:placeholder="t('CAPTAIN.ASSETS.FORM.NAME.PLACEHOLDER')"
|
||||||
|
:message="formErrors.name"
|
||||||
|
:message-type="formErrors.name ? 'error' : 'info'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-n-slate-12">
|
||||||
|
{{ t('CAPTAIN.ASSETS.FORM.FILE.LABEL') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleFileChange"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
:color="formErrors.file ? 'ruby' : 'slate'"
|
||||||
|
:variant="formErrors.file ? 'outline' : 'solid'"
|
||||||
|
class="!w-full !h-auto !justify-between !py-4"
|
||||||
|
:is-loading="isLoading"
|
||||||
|
@click="openFileDialog"
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<div
|
||||||
|
class="flex justify-center items-center w-10 h-10 rounded-lg bg-n-slate-3"
|
||||||
|
>
|
||||||
|
<i class="text-xl i-ph-image text-n-slate-11" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col flex-1 gap-1 items-start">
|
||||||
|
<p class="m-0 text-sm font-medium text-n-slate-12">
|
||||||
|
{{
|
||||||
|
state.file
|
||||||
|
? state.file.name
|
||||||
|
: t('CAPTAIN.ASSETS.FORM.FILE.CHOOSE_FILE')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p class="m-0 text-xs text-n-slate-11">
|
||||||
|
{{
|
||||||
|
state.file
|
||||||
|
? `${(state.file.size / 1024 / 1024).toFixed(2)} MB`
|
||||||
|
: t('CAPTAIN.ASSETS.FORM.FILE.HELP_TEXT')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<span v-if="formErrors.file" class="text-xs text-n-ruby-11">
|
||||||
|
{{ formErrors.file }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="faded"
|
||||||
|
color="slate"
|
||||||
|
:label="t('CAPTAIN.FORM.CANCEL')"
|
||||||
|
@click="handleCancel"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
:label="t(isEditMode ? 'CAPTAIN.FORM.EDIT' : 'CAPTAIN.FORM.CREATE')"
|
||||||
|
@click="handleSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useStore } from 'dashboard/composables/store';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||||
|
|
||||||
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
import AssetForm from './AssetForm.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['close']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
const dialogRef = ref(null);
|
||||||
|
const i18nKey = 'CAPTAIN.ASSETS.CREATE';
|
||||||
|
|
||||||
|
const handleSubmit = async newAsset => {
|
||||||
|
try {
|
||||||
|
await store.dispatch('captainAssets/create', newAsset);
|
||||||
|
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
|
||||||
|
dialogRef.value.close();
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
parseAPIErrorResponse(error) || t(`${i18nKey}.ERROR_MESSAGE`);
|
||||||
|
useAlert(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
dialogRef.value.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ dialogRef });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
|
<Dialog
|
||||||
|
ref="dialogRef"
|
||||||
|
:title="$t(`${i18nKey}.TITLE`)"
|
||||||
|
:description="$t('CAPTAIN.ASSETS.FORM_DESCRIPTION')"
|
||||||
|
:show-cancel-button="false"
|
||||||
|
:show-confirm-button="false"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<AssetForm mode="create" @submit="handleSubmit" @cancel="handleCancel" />
|
||||||
|
<template #footer />
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useStore } from 'dashboard/composables/store';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||||
|
|
||||||
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
import AssetForm from './AssetForm.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
asset: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
const dialogRef = ref(null);
|
||||||
|
const i18nKey = 'CAPTAIN.ASSETS.EDIT';
|
||||||
|
|
||||||
|
const handleSubmit = async updatedAsset => {
|
||||||
|
try {
|
||||||
|
await store.dispatch('captainAssets/update', {
|
||||||
|
id: props.asset.id,
|
||||||
|
payload: updatedAsset,
|
||||||
|
});
|
||||||
|
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
|
||||||
|
dialogRef.value.close();
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
parseAPIErrorResponse(error) || t(`${i18nKey}.ERROR_MESSAGE`);
|
||||||
|
useAlert(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
dialogRef.value.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ dialogRef });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
|
<Dialog
|
||||||
|
ref="dialogRef"
|
||||||
|
:title="$t(`${i18nKey}.TITLE`)"
|
||||||
|
:description="$t('CAPTAIN.ASSETS.FORM_DESCRIPTION')"
|
||||||
|
:show-cancel-button="false"
|
||||||
|
:show-confirm-button="false"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<AssetForm
|
||||||
|
mode="edit"
|
||||||
|
:asset="asset"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
/>
|
||||||
|
<template #footer />
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@ -111,6 +111,7 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||||
<Input
|
<Input
|
||||||
v-model="state.name"
|
v-model="state.name"
|
||||||
|
|||||||
@ -75,6 +75,7 @@ defineExpose({ dialogRef });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<Dialog
|
<Dialog
|
||||||
ref="dialogRef"
|
ref="dialogRef"
|
||||||
type="edit"
|
type="edit"
|
||||||
|
|||||||
@ -163,6 +163,7 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<Input
|
<Input
|
||||||
v-model="state.name"
|
v-model="state.name"
|
||||||
|
|||||||
@ -17,6 +17,7 @@ const onClick = name => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<div
|
<div
|
||||||
:key="controlItem.name"
|
:key="controlItem.name"
|
||||||
class="pt-3 ltr:pl-4 rtl:pr-4 ltr:pr-2 rtl:pl-2 pb-5 gap-2 flex flex-col w-full shadow outline-1 outline outline-n-container rounded-2xl bg-n-solid-2 cursor-pointer"
|
class="pt-3 ltr:pl-4 rtl:pr-4 ltr:pr-2 rtl:pl-2 pb-5 gap-2 flex flex-col w-full shadow outline-1 outline outline-n-container rounded-2xl bg-n-solid-2 cursor-pointer"
|
||||||
|
|||||||
@ -277,6 +277,7 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<Editor
|
<Editor
|
||||||
v-model="state.handoffMessage"
|
v-model="state.handoffMessage"
|
||||||
|
|||||||
@ -0,0 +1,209 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactive, computed, watch, ref, useTemplateRef } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
|
import { requiredIf, url } from '@vuelidate/validators';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import CaptainAssistantAPI from 'dashboard/api/captain/assistant';
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||||
|
import HeaderRow from 'dashboard/components-next/captain/pageComponents/customTool/HeaderRow.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
assistant: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['submit']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const isTesting = ref(false);
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
enabled: false,
|
||||||
|
url: '',
|
||||||
|
headers: [],
|
||||||
|
retry_attempts: 3,
|
||||||
|
timeout_seconds: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const headersRef = useTemplateRef('headersRef');
|
||||||
|
|
||||||
|
const DEFAULT_HEADER = { key: '', value: '' };
|
||||||
|
|
||||||
|
const validationRules = {
|
||||||
|
url: {
|
||||||
|
required: requiredIf(() => state.enabled),
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const v$ = useVuelidate(validationRules, state);
|
||||||
|
|
||||||
|
const updateStateFromAssistant = assistant => {
|
||||||
|
const config = assistant?.handoff_webhook_config || {};
|
||||||
|
state.enabled = !!config.enabled;
|
||||||
|
state.url = config.url || '';
|
||||||
|
state.retry_attempts = config.retry_attempts ?? 3;
|
||||||
|
state.timeout_seconds = config.timeout_seconds ?? 5;
|
||||||
|
state.headers = Object.entries(config.headers || {}).map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.assistant,
|
||||||
|
assistant => {
|
||||||
|
if (assistant) updateStateFromAssistant(assistant);
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const addHeader = () => {
|
||||||
|
state.headers.push({ ...DEFAULT_HEADER });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeHeader = index => {
|
||||||
|
state.headers.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isHeadersValid = () => {
|
||||||
|
if (!headersRef.value || headersRef.value.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return headersRef.value.every(header => header.validate());
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFormValid = computed(() => {
|
||||||
|
if (!state.enabled) return true;
|
||||||
|
return !v$.value.$invalid && isHeadersValid();
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildHeadersPayload = () => {
|
||||||
|
if (!state.headers.length) return {};
|
||||||
|
|
||||||
|
return state.headers.reduce((acc, header) => {
|
||||||
|
if (header.key && header.value) {
|
||||||
|
acc[header.key] = header.value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const isValid = await v$.value.$validate();
|
||||||
|
if (!isValid || !isHeadersValid()) return;
|
||||||
|
|
||||||
|
emit('submit', {
|
||||||
|
handoff_webhook_config: {
|
||||||
|
enabled: state.enabled,
|
||||||
|
url: state.url,
|
||||||
|
retry_attempts: state.retry_attempts,
|
||||||
|
timeout_seconds: state.timeout_seconds,
|
||||||
|
headers: buildHeadersPayload(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const testWebhook = async () => {
|
||||||
|
isTesting.value = true;
|
||||||
|
try {
|
||||||
|
await CaptainAssistantAPI.testWebhook(props.assistant.id);
|
||||||
|
useAlert(t('CAPTAIN.ASSISTANTS.FORM.WEBHOOK.TEST_SENT'));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error?.response?.data?.error || error.message;
|
||||||
|
useAlert(message);
|
||||||
|
} finally {
|
||||||
|
isTesting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-n-slate-12">
|
||||||
|
{{ t('CAPTAIN.ASSISTANTS.FORM.WEBHOOK.TITLE') }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-n-slate-11">
|
||||||
|
{{ t('CAPTAIN.ASSISTANTS.FORM.WEBHOOK.DESCRIPTION') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch v-model="state.enabled" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="state.enabled" class="flex flex-col gap-4">
|
||||||
|
<Input
|
||||||
|
v-model="state.url"
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.FORM.WEBHOOK.URL_LABEL')"
|
||||||
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.WEBHOOK.URL_PLACEHOLDER')"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-n-slate-12">
|
||||||
|
{{ t('CAPTAIN.ASSISTANTS.FORM.WEBHOOK.HEADERS_LABEL') }}
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-n-slate-11">
|
||||||
|
{{ t('CAPTAIN.ASSISTANTS.FORM.WEBHOOK.HEADERS_HELP') }}
|
||||||
|
</p>
|
||||||
|
<ul v-if="state.headers.length" class="grid gap-2 list-none">
|
||||||
|
<HeaderRow
|
||||||
|
v-for="(header, index) in state.headers"
|
||||||
|
:key="index"
|
||||||
|
ref="headersRef"
|
||||||
|
v-model:key="header.key"
|
||||||
|
v-model:value="header.value"
|
||||||
|
@remove="removeHeader(index)"
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
<Button
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.FORM.WEBHOOK.ADD_HEADER')"
|
||||||
|
variant="faded"
|
||||||
|
color="slate"
|
||||||
|
@click="addHeader"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
v-model.number="state.retry_attempts"
|
||||||
|
type="number"
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.FORM.WEBHOOK.RETRY_LABEL')"
|
||||||
|
min="0"
|
||||||
|
max="5"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model.number="state.timeout_seconds"
|
||||||
|
type="number"
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.FORM.WEBHOOK.TIMEOUT_LABEL')"
|
||||||
|
min="1"
|
||||||
|
max="30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
||||||
|
:disabled="!isFormValid"
|
||||||
|
@click="handleSubmit"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="state.enabled"
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.FORM.WEBHOOK.TEST_BUTTON')"
|
||||||
|
variant="faded"
|
||||||
|
color="slate"
|
||||||
|
:is-loading="isTesting"
|
||||||
|
:disabled="isTesting"
|
||||||
|
@click="testWebhook"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -27,6 +27,7 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<Input
|
<Input
|
||||||
v-if="authType === 'bearer'"
|
v-if="authType === 'bearer'"
|
||||||
|
|||||||
@ -67,6 +67,7 @@ defineExpose({ dialogRef });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<Dialog
|
<Dialog
|
||||||
ref="dialogRef"
|
ref="dialogRef"
|
||||||
width="2xl"
|
width="2xl"
|
||||||
|
|||||||
@ -80,6 +80,7 @@ const authTypeLabel = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<CardLayout class="relative">
|
<CardLayout class="relative">
|
||||||
<div class="flex relative justify-between w-full gap-1">
|
<div class="flex relative justify-between w-full gap-1">
|
||||||
<span class="text-base text-n-slate-12 line-clamp-1 font-medium">
|
<span class="text-base text-n-slate-12 line-clamp-1 font-medium">
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, computed, useTemplateRef, watch } from 'vue';
|
import { reactive, computed, useTemplateRef, watch, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { required } from '@vuelidate/validators';
|
import { required } from '@vuelidate/validators';
|
||||||
@ -10,6 +10,7 @@ import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
|||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||||
import ParamRow from './ParamRow.vue';
|
import ParamRow from './ParamRow.vue';
|
||||||
|
import HeaderRow from './HeaderRow.vue';
|
||||||
import AuthConfig from './AuthConfig.vue';
|
import AuthConfig from './AuthConfig.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -45,6 +46,7 @@ const initialState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const state = reactive({ ...initialState });
|
const state = reactive({ ...initialState });
|
||||||
|
const customHeaders = ref([]);
|
||||||
|
|
||||||
// Populate form when in edit mode
|
// Populate form when in edit mode
|
||||||
watch(
|
watch(
|
||||||
@ -60,6 +62,15 @@ watch(
|
|||||||
state.auth_type = newTool.auth_type || 'none';
|
state.auth_type = newTool.auth_type || 'none';
|
||||||
state.auth_config = newTool.auth_config || {};
|
state.auth_config = newTool.auth_config || {};
|
||||||
state.param_schema = newTool.param_schema || [];
|
state.param_schema = newTool.param_schema || [];
|
||||||
|
|
||||||
|
// Extract headers from auth_config if present
|
||||||
|
if (state.auth_config && state.auth_config.headers) {
|
||||||
|
customHeaders.value = Object.entries(state.auth_config.headers).map(
|
||||||
|
([key, value]) => ({ key, value })
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
customHeaders.value = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
@ -114,6 +125,7 @@ const formErrors = computed(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const paramsRef = useTemplateRef('paramsRef');
|
const paramsRef = useTemplateRef('paramsRef');
|
||||||
|
const headersRef = useTemplateRef('headersRef');
|
||||||
|
|
||||||
const isParamsValid = () => {
|
const isParamsValid = () => {
|
||||||
if (!paramsRef.value || paramsRef.value.length === 0) {
|
if (!paramsRef.value || paramsRef.value.length === 0) {
|
||||||
@ -122,6 +134,13 @@ const isParamsValid = () => {
|
|||||||
return paramsRef.value.every(param => param.validate());
|
return paramsRef.value.every(param => param.validate());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isHeadersValid = () => {
|
||||||
|
if (!headersRef.value || headersRef.value.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return headersRef.value.every(header => header.validate());
|
||||||
|
};
|
||||||
|
|
||||||
const removeParam = index => {
|
const removeParam = index => {
|
||||||
state.param_schema.splice(index, 1);
|
state.param_schema.splice(index, 1);
|
||||||
};
|
};
|
||||||
@ -130,19 +149,42 @@ const addParam = () => {
|
|||||||
state.param_schema.push({ ...DEFAULT_PARAM });
|
state.param_schema.push({ ...DEFAULT_PARAM });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const removeHeader = index => {
|
||||||
|
customHeaders.value.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addHeader = () => {
|
||||||
|
customHeaders.value.push({ key: '', value: '' });
|
||||||
|
};
|
||||||
|
|
||||||
const handleCancel = () => emit('cancel');
|
const handleCancel = () => emit('cancel');
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
const isFormValid = await v$.value.$validate();
|
const isFormValid = await v$.value.$validate();
|
||||||
if (!isFormValid || !isParamsValid()) {
|
if (!isFormValid || !isParamsValid() || !isHeadersValid()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merge headers into auth_config
|
||||||
|
if (customHeaders.value.length > 0) {
|
||||||
|
const headersHash = customHeaders.value.reduce((acc, header) => {
|
||||||
|
acc[header.key] = header.value;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
state.auth_config = { ...state.auth_config, headers: headersHash };
|
||||||
|
} else {
|
||||||
|
// If headers exist in auth_config but are removed in UI, we need to remove them
|
||||||
|
// but preserve other auth keys.
|
||||||
|
const { headers, ...restIdx } = state.auth_config;
|
||||||
|
state.auth_config = restIdx;
|
||||||
|
}
|
||||||
|
|
||||||
emit('submit', state);
|
emit('submit', state);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<form
|
<form
|
||||||
class="flex flex-col px-4 -mx-4 gap-4 max-h-[calc(100vh-200px)] overflow-y-scroll"
|
class="flex flex-col px-4 -mx-4 gap-4 max-h-[calc(100vh-200px)] overflow-y-scroll"
|
||||||
@submit.prevent="handleSubmit"
|
@submit.prevent="handleSubmit"
|
||||||
@ -199,6 +241,34 @@ const handleSubmit = async () => {
|
|||||||
:auth-type="state.auth_type"
|
:auth-type="state.auth_type"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-n-slate-12">
|
||||||
|
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.HEADERS.LABEL') }}
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-n-slate-11 -mt-1">
|
||||||
|
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.HEADERS.HELP_TEXT') }}
|
||||||
|
</p>
|
||||||
|
<ul v-if="customHeaders.length > 0" class="grid gap-2 list-none">
|
||||||
|
<HeaderRow
|
||||||
|
v-for="(header, index) in customHeaders"
|
||||||
|
:key="index"
|
||||||
|
ref="headersRef"
|
||||||
|
v-model:key="header.key"
|
||||||
|
v-model:value="header.value"
|
||||||
|
@remove="removeHeader(index)"
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
sm
|
||||||
|
ghost
|
||||||
|
blue
|
||||||
|
icon="i-lucide-plus"
|
||||||
|
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.ADD_HEADER')"
|
||||||
|
@click="addHeader"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label class="text-sm font-medium text-n-slate-12">
|
<label class="text-sm font-medium text-n-slate-12">
|
||||||
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAMETERS.LABEL') }}
|
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAMETERS.LABEL') }}
|
||||||
|
|||||||
@ -0,0 +1,81 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, defineModel, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['remove']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
const showErrors = ref(false);
|
||||||
|
|
||||||
|
const key = defineModel('key', {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const value = defineModel('value', {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const validationError = computed(() => {
|
||||||
|
if (!key.value || key.value.trim() === '') {
|
||||||
|
return 'HEADER_KEY_REQUIRED';
|
||||||
|
}
|
||||||
|
if (!value.value || value.value.trim() === '') {
|
||||||
|
return 'HEADER_VALUE_REQUIRED';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch([key, value], () => {
|
||||||
|
showErrors.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
showErrors.value = true;
|
||||||
|
return !validationError.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ validate });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
|
<li class="list-none">
|
||||||
|
<div
|
||||||
|
class="flex items-start gap-2 p-3 rounded-lg border border-n-weak bg-n-alpha-2"
|
||||||
|
:class="{
|
||||||
|
'animate-wiggle border-n-ruby-9': showErrors && validationError,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col flex-1 gap-3">
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<Input
|
||||||
|
v-model="key"
|
||||||
|
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.HEADER_KEY.PLACEHOLDER')"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="value"
|
||||||
|
:placeholder="
|
||||||
|
t('CAPTAIN.CUSTOM_TOOLS.FORM.HEADER_VALUE.PLACEHOLDER')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
solid
|
||||||
|
slate
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
@click.stop="emit('remove')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="showErrors && validationError"
|
||||||
|
class="block mt-1 text-sm text-n-ruby-11"
|
||||||
|
>
|
||||||
|
{{ t(`CAPTAIN.CUSTOM_TOOLS.FORM.ERRORS.${validationError}`) }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
@ -61,6 +61,7 @@ defineExpose({ validate });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<li class="list-none">
|
<li class="list-none">
|
||||||
<div
|
<div
|
||||||
class="flex items-start gap-2 p-3 rounded-lg border border-n-weak bg-n-alpha-2"
|
class="flex items-start gap-2 p-3 rounded-lg border border-n-weak bg-n-alpha-2"
|
||||||
|
|||||||
@ -59,6 +59,7 @@ defineExpose({ dialogRef });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<Dialog
|
<Dialog
|
||||||
ref="dialogRef"
|
ref="dialogRef"
|
||||||
width="3xl"
|
width="3xl"
|
||||||
|
|||||||
@ -47,6 +47,7 @@ defineExpose({ dialogRef });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<Dialog
|
<Dialog
|
||||||
ref="dialogRef"
|
ref="dialogRef"
|
||||||
:title="$t(`${i18nKey}.TITLE`)"
|
:title="$t(`${i18nKey}.TITLE`)"
|
||||||
|
|||||||
@ -130,6 +130,7 @@ const handleSubmit = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<label
|
<label
|
||||||
|
|||||||
@ -29,6 +29,7 @@ onMounted(fetchLimits);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<Banner
|
<Banner
|
||||||
v-show="showBanner"
|
v-show="showBanner"
|
||||||
color="amber"
|
color="amber"
|
||||||
|
|||||||
@ -34,6 +34,7 @@ defineExpose({ dialogRef });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<Dialog
|
<Dialog
|
||||||
ref="dialogRef"
|
ref="dialogRef"
|
||||||
type="edit"
|
type="edit"
|
||||||
|
|||||||
@ -15,6 +15,7 @@ const onClick = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<FeatureSpotlight
|
<FeatureSpotlight
|
||||||
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
||||||
:note="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
|
:note="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
|
||||||
|
|||||||
@ -10,6 +10,7 @@ const onClick = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<EmptyStateLayout
|
<EmptyStateLayout
|
||||||
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')"
|
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')"
|
||||||
:subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')"
|
:subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')"
|
||||||
|
|||||||
@ -18,6 +18,7 @@ const onClick = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<FeatureSpotlight
|
<FeatureSpotlight
|
||||||
:title="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
:title="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
||||||
:note="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
|
:note="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
|
||||||
|
|||||||
@ -12,6 +12,7 @@ const onClick = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<EmptyStateLayout
|
<EmptyStateLayout
|
||||||
:title="$t('CAPTAIN.INBOXES.EMPTY_STATE.TITLE')"
|
:title="$t('CAPTAIN.INBOXES.EMPTY_STATE.TITLE')"
|
||||||
:subtitle="$t('CAPTAIN.INBOXES.EMPTY_STATE.SUBTITLE')"
|
:subtitle="$t('CAPTAIN.INBOXES.EMPTY_STATE.SUBTITLE')"
|
||||||
|
|||||||
@ -39,6 +39,7 @@ const onClearFilters = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<FeatureSpotlight
|
<FeatureSpotlight
|
||||||
v-if="isApproved"
|
v-if="isApproved"
|
||||||
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
||||||
|
|||||||
@ -45,6 +45,7 @@ defineExpose({ dialogRef });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<Dialog
|
<Dialog
|
||||||
ref="dialogRef"
|
ref="dialogRef"
|
||||||
type="create"
|
type="create"
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, computed } from 'vue';
|
import { reactive, computed, onMounted } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { required } from '@vuelidate/validators';
|
import { required } from '@vuelidate/validators';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||||
@ -27,9 +28,11 @@ const formState = {
|
|||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
inboxId: null,
|
inboxId: null,
|
||||||
|
captainUnitId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const state = reactive({ ...initialState });
|
const state = reactive({ ...initialState });
|
||||||
|
const units = reactive([]);
|
||||||
|
|
||||||
const validationRules = {
|
const validationRules = {
|
||||||
inboxId: { required },
|
inboxId: { required },
|
||||||
@ -46,6 +49,13 @@ const inboxList = computed(() => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const unitList = computed(() => {
|
||||||
|
return units.map(unit => ({
|
||||||
|
value: unit.id,
|
||||||
|
label: unit.name,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
const v$ = useVuelidate(validationRules, state);
|
const v$ = useVuelidate(validationRules, state);
|
||||||
|
|
||||||
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
|
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
|
||||||
@ -64,6 +74,7 @@ const handleCancel = () => emit('cancel');
|
|||||||
|
|
||||||
const prepareInboxPayload = () => ({
|
const prepareInboxPayload = () => ({
|
||||||
inboxId: state.inboxId,
|
inboxId: state.inboxId,
|
||||||
|
captainUnitId: state.captainUnitId,
|
||||||
assistantId: props.assistantId,
|
assistantId: props.assistantId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -75,9 +86,19 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
emit('submit', prepareInboxPayload());
|
emit('submit', prepareInboxPayload());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get('/api/v1/accounts/captain/units');
|
||||||
|
units.push(...data);
|
||||||
|
} catch (error) {
|
||||||
|
// Silent fail or alert
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||||
@ -94,6 +115,23 @@ const handleSubmit = async () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Unit Selection -->
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="unit" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||||
|
Unidade (Opcional - Pix)
|
||||||
|
</label>
|
||||||
|
<ComboBox
|
||||||
|
id="unit"
|
||||||
|
v-model="state.captainUnitId"
|
||||||
|
:options="unitList"
|
||||||
|
placeholder="Selecione a unidade financeira"
|
||||||
|
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-n-slate-11">
|
||||||
|
Vincule a uma unidade para ativar Pix automático.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between w-full gap-3">
|
<div class="flex items-center justify-between w-full gap-3">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -72,6 +72,7 @@ defineExpose({ dialogRef });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<Dialog
|
<Dialog
|
||||||
ref="dialogRef"
|
ref="dialogRef"
|
||||||
:title="$t(`${i18nKey}.TITLE`)"
|
:title="$t(`${i18nKey}.TITLE`)"
|
||||||
|
|||||||
@ -31,6 +31,7 @@ onMounted(fetchLimits);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<Banner
|
<Banner
|
||||||
v-show="showBanner"
|
v-show="showBanner"
|
||||||
color="amber"
|
color="amber"
|
||||||
|
|||||||
@ -94,6 +94,7 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||||
<Input
|
<Input
|
||||||
v-model="state.question"
|
v-model="state.question"
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import SettingsHeader from './SettingsHeader.vue';
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<Story
|
<Story
|
||||||
title="Captain/PageComponents/SettingsHeader"
|
title="Captain/PageComponents/SettingsHeader"
|
||||||
:layout="{ type: 'grid', width: '800px' }"
|
:layout="{ type: 'grid', width: '800px' }"
|
||||||
|
|||||||
@ -12,6 +12,7 @@ defineProps({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<header class="flex flex-col items-start gap-2">
|
<header class="flex flex-col items-start gap-2">
|
||||||
<h2 class="text-n-slate-12 text-base font-medium">{{ heading }}</h2>
|
<h2 class="text-n-slate-12 text-base font-medium">{{ heading }}</h2>
|
||||||
<p class="text-n-slate-11 text-sm">{{ description }}</p>
|
<p class="text-n-slate-11 text-sm">{{ description }}</p>
|
||||||
|
|||||||
@ -87,6 +87,7 @@ const openCreateAssistantDialog = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<div
|
<div
|
||||||
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] rounded-xl shadow-md flex flex-col gap-4"
|
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] rounded-xl shadow-md flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -229,6 +229,64 @@ const menuItems = computed(() => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Reservations',
|
||||||
|
icon: 'i-lucide-calendar',
|
||||||
|
label: 'Reservas',
|
||||||
|
activeOn: [
|
||||||
|
'captain_reservations_index',
|
||||||
|
'captain_reminders_index',
|
||||||
|
'captain_configurations_index',
|
||||||
|
'captain_units_index',
|
||||||
|
'captain_brands_index',
|
||||||
|
'captain_pricings_index',
|
||||||
|
'captain_extras_index',
|
||||||
|
],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'Dashboard',
|
||||||
|
label: 'Painel',
|
||||||
|
activeOn: ['captain_reservations_index'],
|
||||||
|
to: accountScopedRoute('captain_reservations_index'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Reminders',
|
||||||
|
label: 'Lembretes',
|
||||||
|
activeOn: ['captain_reminders_index'],
|
||||||
|
to: accountScopedRoute('captain_reminders_index'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Settings',
|
||||||
|
label: 'Configurações',
|
||||||
|
activeOn: ['captain_configurations_index'],
|
||||||
|
to: accountScopedRoute('captain_configurations_index'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Units',
|
||||||
|
label: 'Unidades',
|
||||||
|
activeOn: ['captain_units_index'],
|
||||||
|
to: accountScopedRoute('captain_units_index'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Brands',
|
||||||
|
label: 'Marcas',
|
||||||
|
activeOn: ['captain_brands_index'],
|
||||||
|
to: accountScopedRoute('captain_brands_index'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pricings',
|
||||||
|
label: 'Preços',
|
||||||
|
activeOn: ['captain_pricings_index'],
|
||||||
|
to: accountScopedRoute('captain_pricings_index'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Extras',
|
||||||
|
label: 'Extras',
|
||||||
|
activeOn: ['captain_extras_index'],
|
||||||
|
to: accountScopedRoute('captain_extras_index'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Captain',
|
name: 'Captain',
|
||||||
icon: 'i-woot-captain',
|
icon: 'i-woot-captain',
|
||||||
@ -254,6 +312,12 @@ const menuItems = computed(() => {
|
|||||||
navigationPath: 'captain_assistants_documents_index',
|
navigationPath: 'captain_assistants_documents_index',
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Assets',
|
||||||
|
label: t('SIDEBAR.CAPTAIN_ASSETS'),
|
||||||
|
activeOn: ['captain_assets_index'],
|
||||||
|
to: accountScopedRoute('captain_assets_index'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Scenarios',
|
name: 'Scenarios',
|
||||||
label: t('SIDEBAR.CAPTAIN_SCENARIOS'),
|
label: t('SIDEBAR.CAPTAIN_SCENARIOS'),
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import router from '../../routes/index';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
backUrl: {
|
backUrl: {
|
||||||
type: [String, Object],
|
type: [String, Object],
|
||||||
@ -15,6 +16,8 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
if (props.backUrl !== '') {
|
if (props.backUrl !== '') {
|
||||||
router.push(props.backUrl);
|
router.push(props.backUrl);
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
|||||||
|
|
||||||
export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
|
export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
|
||||||
{ name: 'conversation_actions' },
|
{ name: 'conversation_actions' },
|
||||||
|
{ name: 'captain_reservations' },
|
||||||
{ name: 'macros' },
|
{ name: 'macros' },
|
||||||
{ name: 'conversation_info' },
|
{ name: 'conversation_info' },
|
||||||
{ name: 'contact_attributes' },
|
{ name: 'contact_attributes' },
|
||||||
|
|||||||
@ -421,6 +421,7 @@
|
|||||||
"CONTACT_ATTRIBUTES": "Contact Attributes",
|
"CONTACT_ATTRIBUTES": "Contact Attributes",
|
||||||
"PREVIOUS_CONVERSATION": "Previous Conversations",
|
"PREVIOUS_CONVERSATION": "Previous Conversations",
|
||||||
"MACROS": "Macros",
|
"MACROS": "Macros",
|
||||||
|
"CAPTAIN_RESERVATIONS": "Reservations & Reminders",
|
||||||
"LINEAR_ISSUES": "Linked Linear Issues",
|
"LINEAR_ISSUES": "Linked Linear Issues",
|
||||||
"SHOPIFY_ORDERS": "Shopify Orders"
|
"SHOPIFY_ORDERS": "Shopify Orders"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -547,6 +547,19 @@
|
|||||||
"ALLOW_CITATIONS": "Include source citations in responses",
|
"ALLOW_CITATIONS": "Include source citations in responses",
|
||||||
"ALLOW_SENTIMENT_HANDOFF": "Automatically handoff to human on negative sentiment (angry/frustrated)"
|
"ALLOW_SENTIMENT_HANDOFF": "Automatically handoff to human on negative sentiment (angry/frustrated)"
|
||||||
},
|
},
|
||||||
|
"WEBHOOK": {
|
||||||
|
"TITLE": "Handoff Webhook",
|
||||||
|
"DESCRIPTION": "Notify external systems when a conversation is handed off to a human agent",
|
||||||
|
"URL_LABEL": "Webhook URL",
|
||||||
|
"URL_PLACEHOLDER": "https://api.example.com/handoff",
|
||||||
|
"HEADERS_LABEL": "Custom Headers (Optional)",
|
||||||
|
"HEADERS_HELP": "Add custom headers for authentication (e.g., Authorization: Bearer TOKEN)",
|
||||||
|
"ADD_HEADER": "+ Add Header",
|
||||||
|
"RETRY_LABEL": "Retry Attempts",
|
||||||
|
"TIMEOUT_LABEL": "Timeout (seconds)",
|
||||||
|
"TEST_BUTTON": "Test Webhook",
|
||||||
|
"TEST_SENT": "Test webhook sent!"
|
||||||
|
},
|
||||||
"LLM_PROVIDER": {
|
"LLM_PROVIDER": {
|
||||||
"LABEL": "LLM Provider"
|
"LABEL": "LLM Provider"
|
||||||
},
|
},
|
||||||
@ -845,6 +858,169 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ASSETS": {
|
||||||
|
"HEADER": "Media Gallery",
|
||||||
|
"DESCRIPTION": "Upload images and reference them in prompts using {{ media.key }}.",
|
||||||
|
"ADD_NEW": "Upload image",
|
||||||
|
"EMPTY_STATE": "No media assets yet. Upload your first image to get started.",
|
||||||
|
"FORM_DESCRIPTION": "Upload an image and set a unique key to use in prompts.",
|
||||||
|
"SHORTCUT_LABEL": "Shortcut",
|
||||||
|
"COPY_SHORTCUT": {
|
||||||
|
"LABEL": "Copy shortcut",
|
||||||
|
"SUCCESS": "Shortcut copied"
|
||||||
|
},
|
||||||
|
"COPY_URL": {
|
||||||
|
"LABEL": "Copy URL",
|
||||||
|
"SUCCESS": "URL copied"
|
||||||
|
},
|
||||||
|
"FORM": {
|
||||||
|
"NAME": {
|
||||||
|
"LABEL": "Key",
|
||||||
|
"PLACEHOLDER": "suite_master",
|
||||||
|
"ERROR": "Key is required"
|
||||||
|
},
|
||||||
|
"FILE": {
|
||||||
|
"LABEL": "Image file",
|
||||||
|
"CHOOSE_FILE": "Choose image",
|
||||||
|
"ERROR": "Image is required",
|
||||||
|
"HELP_TEXT": "PNG, JPG, GIF, or WEBP up to 10MB",
|
||||||
|
"INVALID_TYPE": "Only PNG, JPG, GIF, or WEBP files are allowed",
|
||||||
|
"TOO_LARGE": "File must be smaller than 10MB"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CREATE": {
|
||||||
|
"TITLE": "Upload image",
|
||||||
|
"SUCCESS_MESSAGE": "Image uploaded successfully",
|
||||||
|
"ERROR_MESSAGE": "There was an error uploading the image"
|
||||||
|
},
|
||||||
|
"EDIT": {
|
||||||
|
"TITLE": "Update image",
|
||||||
|
"SUCCESS_MESSAGE": "Image updated successfully",
|
||||||
|
"ERROR_MESSAGE": "There was an error updating the image"
|
||||||
|
},
|
||||||
|
"DELETE": {
|
||||||
|
"TITLE": "Delete image",
|
||||||
|
"DESCRIPTION": "This action is permanent. Deleting this image will break any prompts that reference it.",
|
||||||
|
"CONFIRM": "Yes, delete",
|
||||||
|
"SUCCESS_MESSAGE": "Image deleted successfully",
|
||||||
|
"ERROR_MESSAGE": "There was an error deleting the image"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RESERVATIONS": {
|
||||||
|
"AUTOMATIONS": {
|
||||||
|
"TITLE": "Automations",
|
||||||
|
"LIST_TITLE": "Existing automations",
|
||||||
|
"EMPTY": "No automations configured",
|
||||||
|
"EDIT": "Edit",
|
||||||
|
"DELETE": "Delete",
|
||||||
|
"TRIGGER_CHECK_IN": "Check-in",
|
||||||
|
"TRIGGER_CHECK_OUT": "Check-out",
|
||||||
|
"TIMING_BEFORE": "Before",
|
||||||
|
"TIMING_AFTER": "After",
|
||||||
|
"FORM": {
|
||||||
|
"TITLE": "Title",
|
||||||
|
"MESSAGE": "Message",
|
||||||
|
"TRIGGER": "Trigger",
|
||||||
|
"TIMING": "Timing",
|
||||||
|
"MINUTES": "Minutes",
|
||||||
|
"SUBMIT": "Add automation",
|
||||||
|
"UPDATE": "Update automation",
|
||||||
|
"CANCEL": "Cancel"
|
||||||
|
},
|
||||||
|
"SUCCESS": {
|
||||||
|
"CREATED": "Automation created",
|
||||||
|
"UPDATED": "Automation updated",
|
||||||
|
"DELETED": "Automation deleted"
|
||||||
|
},
|
||||||
|
"ERRORS": {
|
||||||
|
"LOAD_FAILED": "Could not load automations",
|
||||||
|
"MISSING_FIELDS": "Fill in title and message",
|
||||||
|
"CREATE_FAILED": "Could not create automation",
|
||||||
|
"UPDATE_FAILED": "Could not update automation",
|
||||||
|
"DELETE_FAILED": "Could not delete automation"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PAGE": {
|
||||||
|
"HEADER": "Reservations & Reminders",
|
||||||
|
"DESCRIPTION": "Create manual reservations and reminders tied to an inbox.",
|
||||||
|
"INBOX_LABEL": "Inbox",
|
||||||
|
"INBOX_PLACEHOLDER": "Select an inbox",
|
||||||
|
"NO_INBOXES": "No inboxes available",
|
||||||
|
"RESERVATION_TITLE": "Manual reservation",
|
||||||
|
"CONTACT_NAME_LABEL": "Customer name (optional)",
|
||||||
|
"CONTACT_NAME_PLACEHOLDER": "Maria",
|
||||||
|
"PHONE_LABEL": "Customer WhatsApp",
|
||||||
|
"PHONE_PLACEHOLDER": "+55 11 99999-9999",
|
||||||
|
"NO_RESERVATIONS": "No reservations for this inbox",
|
||||||
|
"FILTERS_TITLE": "Filters",
|
||||||
|
"FILTERS_FROM": "From",
|
||||||
|
"FILTERS_TO": "To",
|
||||||
|
"LOAD_MORE": "Load more",
|
||||||
|
"ERRORS": {
|
||||||
|
"INBOX_REQUIRED": "Select an inbox",
|
||||||
|
"PHONE_REQUIRED": "Phone number is required"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SECTION_TITLE": "Reservation",
|
||||||
|
"LIST_TITLE": "Upcoming reservations",
|
||||||
|
"NO_SUITE": "Suite not set",
|
||||||
|
"FORM": {
|
||||||
|
"SUITE_LABEL": "Suite",
|
||||||
|
"SUITE_PLACEHOLDER": "102",
|
||||||
|
"CHECK_IN_LABEL": "Check-in time",
|
||||||
|
"DURATION_LABEL": "Duration (hours)",
|
||||||
|
"CHECK_OUT_PREVIEW": "Estimated check-out",
|
||||||
|
"SUBMIT": "Create reservation"
|
||||||
|
},
|
||||||
|
"SETTINGS": {
|
||||||
|
"TITLE": "Automation messages",
|
||||||
|
"ENABLED": "Enable automations",
|
||||||
|
"MENU_DELAY": "Menu delay (minutes)",
|
||||||
|
"MENU_MESSAGE": "Menu message",
|
||||||
|
"FEEDBACK_DELAY": "Feedback delay (minutes)",
|
||||||
|
"FEEDBACK_MESSAGE": "Feedback message",
|
||||||
|
"SAVE": "Save settings",
|
||||||
|
"SAVED": "Settings updated"
|
||||||
|
},
|
||||||
|
"SUCCESS": {
|
||||||
|
"CREATED": "Reservation created",
|
||||||
|
"CANCELLED": "Reservation cancelled"
|
||||||
|
},
|
||||||
|
"ERRORS": {
|
||||||
|
"LOAD_FAILED": "Could not load reservations",
|
||||||
|
"CREATE_FAILED": "Could not create reservation",
|
||||||
|
"MISSING_RESERVATION_FIELDS": "Fill in check-in and duration",
|
||||||
|
"CANCEL_FAILED": "Could not cancel reservation",
|
||||||
|
"SETTINGS_LOAD_FAILED": "Could not load automation settings",
|
||||||
|
"SETTINGS_SAVE_FAILED": "Could not save automation settings"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"REMINDERS": {
|
||||||
|
"PAGE": {
|
||||||
|
"TITLE": "Manual reminder",
|
||||||
|
"NO_REMINDERS": "No reminders for this inbox",
|
||||||
|
"LOAD_MORE": "Load more"
|
||||||
|
},
|
||||||
|
"SECTION_TITLE": "Reminder",
|
||||||
|
"LIST_TITLE": "Scheduled reminders",
|
||||||
|
"FORM": {
|
||||||
|
"MESSAGE_LABEL": "Message",
|
||||||
|
"MESSAGE_PLACEHOLDER": "Your suite is available...",
|
||||||
|
"TIME_LABEL": "Send at",
|
||||||
|
"SUBMIT": "Schedule reminder"
|
||||||
|
},
|
||||||
|
"SUCCESS": {
|
||||||
|
"CREATED": "Reminder scheduled",
|
||||||
|
"CANCELLED": "Reminder cancelled"
|
||||||
|
},
|
||||||
|
"ERRORS": {
|
||||||
|
"LOAD_FAILED": "Could not load reminders",
|
||||||
|
"MISSING_FIELDS": "Fill in message and time",
|
||||||
|
"PHONE_REQUIRED": "Phone number is required",
|
||||||
|
"CREATE_FAILED": "Could not schedule reminder",
|
||||||
|
"CANCEL_FAILED": "Could not cancel reminder"
|
||||||
|
}
|
||||||
|
},
|
||||||
"CUSTOM_TOOLS": {
|
"CUSTOM_TOOLS": {
|
||||||
"HEADER": "Tools",
|
"HEADER": "Tools",
|
||||||
"ADD_NEW": "Create a new tool",
|
"ADD_NEW": "Create a new tool",
|
||||||
@ -949,8 +1125,21 @@
|
|||||||
"LABEL": "Response Template (Optional)",
|
"LABEL": "Response Template (Optional)",
|
||||||
"PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}"
|
"PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}"
|
||||||
},
|
},
|
||||||
|
"HEADERS": {
|
||||||
|
"LABEL": "Custom Headers",
|
||||||
|
"HELP_TEXT": "Define static headers that will be sent with every request (e.g. Authorization, PLUG-PLAY-TOKEN)."
|
||||||
|
},
|
||||||
|
"ADD_HEADER": "Add Header",
|
||||||
|
"HEADER_KEY": {
|
||||||
|
"PLACEHOLDER": "Header Name (e.g. X-Auth-Token)"
|
||||||
|
},
|
||||||
|
"HEADER_VALUE": {
|
||||||
|
"PLACEHOLDER": "Value"
|
||||||
|
},
|
||||||
"ERRORS": {
|
"ERRORS": {
|
||||||
"PARAM_NAME_REQUIRED": "Parameter name is required"
|
"PARAM_NAME_REQUIRED": "Parameter name is required",
|
||||||
|
"HEADER_KEY_REQUIRED": "Header name is required",
|
||||||
|
"HEADER_VALUE_REQUIRED": "Header value is required"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -1043,6 +1232,12 @@
|
|||||||
"INBOXES": {
|
"INBOXES": {
|
||||||
"HEADER": "Connected Inboxes",
|
"HEADER": "Connected Inboxes",
|
||||||
"ADD_NEW": "Connect a new inbox",
|
"ADD_NEW": "Connect a new inbox",
|
||||||
|
"REMINDER_TOOL": {
|
||||||
|
"LABEL": "Always use reminder tool",
|
||||||
|
"HELP": "When enabled, the assistant must schedule reminders using the reminder tool.",
|
||||||
|
"SUCCESS": "Reminder tool preference updated",
|
||||||
|
"ERROR": "Could not update reminder tool preference"
|
||||||
|
},
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
"DISCONNECT": "Disconnect"
|
"DISCONNECT": "Disconnect"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -312,6 +312,8 @@
|
|||||||
"CAPTAIN": "Captain",
|
"CAPTAIN": "Captain",
|
||||||
"CAPTAIN_ASSISTANTS": "Assistants",
|
"CAPTAIN_ASSISTANTS": "Assistants",
|
||||||
"CAPTAIN_DOCUMENTS": "Documents",
|
"CAPTAIN_DOCUMENTS": "Documents",
|
||||||
|
"CAPTAIN_ASSETS": "Media Gallery",
|
||||||
|
"CAPTAIN_RESERVATIONS": "Reservations",
|
||||||
"CAPTAIN_RESPONSES": "FAQs",
|
"CAPTAIN_RESPONSES": "FAQs",
|
||||||
"CAPTAIN_TOOLS": "Tools",
|
"CAPTAIN_TOOLS": "Tools",
|
||||||
"CAPTAIN_SCENARIOS": "Scenarios",
|
"CAPTAIN_SCENARIOS": "Scenarios",
|
||||||
|
|||||||
@ -411,6 +411,7 @@
|
|||||||
"CONTACT_ATTRIBUTES": "Atributos do contato",
|
"CONTACT_ATTRIBUTES": "Atributos do contato",
|
||||||
"PREVIOUS_CONVERSATION": "Conversas anteriores",
|
"PREVIOUS_CONVERSATION": "Conversas anteriores",
|
||||||
"MACROS": "Macros",
|
"MACROS": "Macros",
|
||||||
|
"CAPTAIN_RESERVATIONS": "Reservas e lembretes",
|
||||||
"LINEAR_ISSUES": "Problemas do Linear vinculados",
|
"LINEAR_ISSUES": "Problemas do Linear vinculados",
|
||||||
"SHOPIFY_ORDERS": "Shopify Orders"
|
"SHOPIFY_ORDERS": "Shopify Orders"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -147,7 +147,8 @@
|
|||||||
"MAKE_FORMAL": "Usar tom formal",
|
"MAKE_FORMAL": "Usar tom formal",
|
||||||
"SIMPLIFY": "Simplificar"
|
"SIMPLIFY": "Simplificar"
|
||||||
},
|
},
|
||||||
"ASSISTANCE_MODAL": {
|
|
||||||
|
"ASSISTANT_GENERATOR": {
|
||||||
"DRAFT_TITLE": "Conteúdo do rascunho",
|
"DRAFT_TITLE": "Conteúdo do rascunho",
|
||||||
"GENERATED_TITLE": "Conteúdo gerado",
|
"GENERATED_TITLE": "Conteúdo gerado",
|
||||||
"AI_WRITING": "A Inteligência Artificial está escrevendo",
|
"AI_WRITING": "A Inteligência Artificial está escrevendo",
|
||||||
@ -366,6 +367,41 @@
|
|||||||
},
|
},
|
||||||
"CAPTAIN": {
|
"CAPTAIN": {
|
||||||
"NAME": "Capitão",
|
"NAME": "Capitão",
|
||||||
|
"UNITS": {
|
||||||
|
"TITLE": "Unidades & Filiais",
|
||||||
|
"HEADER": "Gerenciar Unidades",
|
||||||
|
"DESCRIPTION": "Gerencie as unidades físicas (motéis/hotéis) e suas chaves Pix.",
|
||||||
|
"NEW_UNIT": "Nova Unidade",
|
||||||
|
"ADD_NEW": "Adicionar Nova Unidade",
|
||||||
|
"ADD_TITLE": "Adicionar Nova Unidade",
|
||||||
|
"ADD_DESC": "Cadastre uma nova filial para gerenciar reservas e pagamentos.",
|
||||||
|
"EDIT_TITLE": "Editar Unidade",
|
||||||
|
"EDIT_DESC": "Atualize as informações e configurações da unidade.",
|
||||||
|
"NAME_REQUIRED": "O nome da unidade é obrigatório",
|
||||||
|
"SAVED_SUCCESS": "Unidade salva com sucesso!",
|
||||||
|
"UPDATE_ERROR": "Erro ao atualizar unidade.",
|
||||||
|
"COPY_LINK": "Copiar Link",
|
||||||
|
"OPEN_PAGE": "Abrir Página",
|
||||||
|
"COPY_LINK_SUCCESS": "Link copiado com sucesso!",
|
||||||
|
"COPY_LINK_ERROR": "Erro ao copiar o link.",
|
||||||
|
"SAVED_ERROR": "Erro ao salvar unidade.",
|
||||||
|
"ERROR_FETCHING": "Erro ao buscar unidades",
|
||||||
|
"FORM": {
|
||||||
|
"NAME_LABEL": "Nome da Unidade",
|
||||||
|
"NAME_PLACEHOLDER": "Ex: Unidade Centro",
|
||||||
|
"NAME_ERROR": "O nome é obrigatório",
|
||||||
|
"BRAND_LABEL": "Marca",
|
||||||
|
"BRAND_PLACEHOLDER": "Selecione uma marca",
|
||||||
|
"FORM_DESCRIPTION": "Preencha os dados da unidade e as credenciais do Pix (Banco Inter).",
|
||||||
|
"PIX_CONFIG_TITLE": "Configuração Pix (Banco Inter)",
|
||||||
|
"PIX_KEY": "Chave Pix",
|
||||||
|
"CLIENT_ID": "Client ID",
|
||||||
|
"CLIENT_SECRET": "Client Secret",
|
||||||
|
"CERT_PATH": "Caminho do Certificado (.pem)",
|
||||||
|
"KEY_PATH": "Caminho da Chave (.pem)",
|
||||||
|
"ACCOUNT_NUMBER": "Conta Corrente"
|
||||||
|
}
|
||||||
|
},
|
||||||
"HEADER_KNOW_MORE": "Know more",
|
"HEADER_KNOW_MORE": "Know more",
|
||||||
"ASSISTANT_SWITCHER": {
|
"ASSISTANT_SWITCHER": {
|
||||||
"ASSISTANTS": "Assistentes",
|
"ASSISTANTS": "Assistentes",
|
||||||
@ -535,6 +571,19 @@
|
|||||||
"ALLOW_MEMORIES": "Capture os principais detalhes como memórias de interações do cliente.",
|
"ALLOW_MEMORIES": "Capture os principais detalhes como memórias de interações do cliente.",
|
||||||
"ALLOW_CITATIONS": "Incluir fonte de citações nas respostas",
|
"ALLOW_CITATIONS": "Incluir fonte de citações nas respostas",
|
||||||
"ALLOW_SENTIMENT_HANDOFF": "Transferir automaticamente para humano em caso de sentimento negativo (raiva/frustração)"
|
"ALLOW_SENTIMENT_HANDOFF": "Transferir automaticamente para humano em caso de sentimento negativo (raiva/frustração)"
|
||||||
|
},
|
||||||
|
"WEBHOOK": {
|
||||||
|
"TITLE": "Webhook de Escalação",
|
||||||
|
"DESCRIPTION": "Notifique sistemas externos quando uma conversa for escalada para atendimento humano",
|
||||||
|
"URL_LABEL": "URL do Webhook",
|
||||||
|
"URL_PLACEHOLDER": "https://api.example.com/handoff",
|
||||||
|
"HEADERS_LABEL": "Headers Personalizados (Opcional)",
|
||||||
|
"HEADERS_HELP": "Adicione headers customizados para autenticação (ex: Authorization: Bearer TOKEN)",
|
||||||
|
"ADD_HEADER": "+ Adicionar Header",
|
||||||
|
"RETRY_LABEL": "Tentativas de Retry",
|
||||||
|
"TIMEOUT_LABEL": "Timeout (segundos)",
|
||||||
|
"TEST_BUTTON": "Testar Webhook",
|
||||||
|
"TEST_SENT": "Webhook de teste enviado!"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"EDIT": {
|
"EDIT": {
|
||||||
@ -822,6 +871,172 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ASSETS": {
|
||||||
|
"HEADER": "Galeria de Midia",
|
||||||
|
"DESCRIPTION": "Envie imagens e referencie nos prompts usando {{ media.chave }}.",
|
||||||
|
"ADD_NEW": "Enviar imagem",
|
||||||
|
"EMPTY_STATE": "Nenhuma imagem ainda. Envie sua primeira imagem para comecar.",
|
||||||
|
"FORM_DESCRIPTION": "Envie uma imagem e defina uma chave unica para usar nos prompts.",
|
||||||
|
"SHORTCUT_LABEL": "Atalho",
|
||||||
|
"COPY_SHORTCUT": {
|
||||||
|
"LABEL": "Copiar atalho",
|
||||||
|
"SUCCESS": "Atalho copiado"
|
||||||
|
},
|
||||||
|
"COPY_URL": {
|
||||||
|
"LABEL": "Copiar URL",
|
||||||
|
"SUCCESS": "URL copiada"
|
||||||
|
},
|
||||||
|
"FORM": {
|
||||||
|
"NAME": {
|
||||||
|
"LABEL": "Chave",
|
||||||
|
"PLACEHOLDER": "suite_master",
|
||||||
|
"ERROR": "A chave e obrigatoria"
|
||||||
|
},
|
||||||
|
"FILE": {
|
||||||
|
"LABEL": "Arquivo de imagem",
|
||||||
|
"CHOOSE_FILE": "Escolher imagem",
|
||||||
|
"ERROR": "A imagem e obrigatoria",
|
||||||
|
"HELP_TEXT": "PNG, JPG, GIF ou WEBP ate 10MB",
|
||||||
|
"INVALID_TYPE": "Apenas arquivos PNG, JPG, GIF ou WEBP sao permitidos",
|
||||||
|
"TOO_LARGE": "O arquivo deve ter menos de 10MB"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CREATE": {
|
||||||
|
"TITLE": "Enviar imagem",
|
||||||
|
"SUCCESS_MESSAGE": "Imagem enviada com sucesso",
|
||||||
|
"ERROR_MESSAGE": "Ocorreu um erro ao enviar a imagem"
|
||||||
|
},
|
||||||
|
"EDIT": {
|
||||||
|
"TITLE": "Atualizar imagem",
|
||||||
|
"SUCCESS_MESSAGE": "Imagem atualizada com sucesso",
|
||||||
|
"ERROR_MESSAGE": "Ocorreu um erro ao atualizar a imagem"
|
||||||
|
},
|
||||||
|
"DELETE": {
|
||||||
|
"TITLE": "Excluir imagem",
|
||||||
|
"DESCRIPTION": "Esta acao e permanente. Excluir esta imagem quebrara prompts que a referenciam.",
|
||||||
|
"CONFIRM": "Sim, excluir",
|
||||||
|
"SUCCESS_MESSAGE": "Imagem excluida com sucesso",
|
||||||
|
"ERROR_MESSAGE": "Ocorreu um erro ao excluir a imagem"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RESERVATIONS": {
|
||||||
|
"AUTOMATIONS": {
|
||||||
|
"TITLE": "Automacoes",
|
||||||
|
"LIST_TITLE": "Automacoes existentes",
|
||||||
|
"EMPTY": "Nenhuma automacao configurada",
|
||||||
|
"EDIT": "Editar",
|
||||||
|
"DELETE": "Excluir",
|
||||||
|
"TRIGGER_CHECK_IN": "Check-in",
|
||||||
|
"TRIGGER_CHECK_OUT": "Check-out",
|
||||||
|
"TIMING_BEFORE": "Antes",
|
||||||
|
"TIMING_AFTER": "Depois",
|
||||||
|
"FORM": {
|
||||||
|
"TITLE": "Titulo",
|
||||||
|
"MESSAGE": "Mensagem",
|
||||||
|
"TRIGGER": "Disparo",
|
||||||
|
"TIMING": "Momento",
|
||||||
|
"MINUTES": "Minutos",
|
||||||
|
"SUBMIT": "Adicionar automacao",
|
||||||
|
"UPDATE": "Atualizar automacao",
|
||||||
|
"CANCEL": "Cancelar"
|
||||||
|
},
|
||||||
|
"SUCCESS": {
|
||||||
|
"CREATED": "Automacao criada",
|
||||||
|
"UPDATED": "Automacao atualizada",
|
||||||
|
"DELETED": "Automacao excluida"
|
||||||
|
},
|
||||||
|
"ERRORS": {
|
||||||
|
"LOAD_FAILED": "Nao foi possivel carregar as automacoes",
|
||||||
|
"MISSING_FIELDS": "Preencha titulo e mensagem",
|
||||||
|
"CREATE_FAILED": "Nao foi possivel criar a automacao",
|
||||||
|
"UPDATE_FAILED": "Nao foi possivel atualizar a automacao",
|
||||||
|
"DELETE_FAILED": "Nao foi possivel excluir a automacao"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PAGE": {
|
||||||
|
"HEADER": "Reservas e lembretes",
|
||||||
|
"DESCRIPTION": "Crie reservas e lembretes manuais vinculados a uma caixa de entrada.",
|
||||||
|
"INBOX_LABEL": "Caixa de entrada",
|
||||||
|
"INBOX_PLACEHOLDER": "Selecione uma caixa de entrada",
|
||||||
|
"NO_INBOXES": "Nenhuma caixa de entrada disponível",
|
||||||
|
"RESERVATION_TITLE": "Reserva manual",
|
||||||
|
"CONTACT_NAME_LABEL": "Nome do cliente (opcional)",
|
||||||
|
"CONTACT_NAME_PLACEHOLDER": "Maria",
|
||||||
|
"PHONE_LABEL": "WhatsApp do cliente",
|
||||||
|
"PHONE_PLACEHOLDER": "+55 11 99999-9999",
|
||||||
|
"NO_RESERVATIONS": "Nenhuma reserva para esta caixa de entrada",
|
||||||
|
"FILTERS_TITLE": "Filtros",
|
||||||
|
"FILTERS_FROM": "De",
|
||||||
|
"FILTERS_TO": "Ate",
|
||||||
|
"LOAD_MORE": "Carregar mais",
|
||||||
|
"ERRORS": {
|
||||||
|
"INBOX_REQUIRED": "Selecione uma caixa de entrada",
|
||||||
|
"PHONE_REQUIRED": "Telefone obrigatorio"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SECTION_TITLE": "Reserva",
|
||||||
|
"LIST_TITLE": "Reservas futuras",
|
||||||
|
"NO_SUITE": "Suite nao informada",
|
||||||
|
"FORM": {
|
||||||
|
"UNIT_LABEL": "Unidade",
|
||||||
|
"UNIT_PLACEHOLDER": "Selecione a unidade",
|
||||||
|
"TOTAL_AMOUNT_LABEL": "Valor Total (R$)",
|
||||||
|
"SUITE_LABEL": "Suite",
|
||||||
|
"SUITE_PLACEHOLDER": "102",
|
||||||
|
"CHECK_IN_LABEL": "Horario de entrada",
|
||||||
|
"DURATION_LABEL": "Duracao (horas)",
|
||||||
|
"CHECK_OUT_PREVIEW": "Horario estimado de saida",
|
||||||
|
"SUBMIT": "Criar reserva"
|
||||||
|
},
|
||||||
|
"SETTINGS": {
|
||||||
|
"TITLE": "Mensagens automaticas",
|
||||||
|
"ENABLED": "Ativar automacoes",
|
||||||
|
"MENU_DELAY": "Atraso do cardapio (minutos)",
|
||||||
|
"MENU_MESSAGE": "Mensagem do cardapio",
|
||||||
|
"FEEDBACK_DELAY": "Atraso da pesquisa (minutos)",
|
||||||
|
"FEEDBACK_MESSAGE": "Mensagem da pesquisa",
|
||||||
|
"SAVE": "Salvar configuracoes",
|
||||||
|
"SAVED": "Configuracoes atualizadas"
|
||||||
|
},
|
||||||
|
"SUCCESS": {
|
||||||
|
"CREATED": "Reserva criada",
|
||||||
|
"CANCELLED": "Reserva cancelada"
|
||||||
|
},
|
||||||
|
"ERRORS": {
|
||||||
|
"LOAD_FAILED": "Nao foi possivel carregar as reservas",
|
||||||
|
"CREATE_FAILED": "Nao foi possivel criar a reserva",
|
||||||
|
"MISSING_RESERVATION_FIELDS": "Preencha horario de entrada e duracao",
|
||||||
|
"CANCEL_FAILED": "Nao foi possivel cancelar a reserva",
|
||||||
|
"SETTINGS_LOAD_FAILED": "Nao foi possivel carregar as configuracoes",
|
||||||
|
"SETTINGS_SAVE_FAILED": "Nao foi possivel salvar as configuracoes"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"REMINDERS": {
|
||||||
|
"PAGE": {
|
||||||
|
"TITLE": "Lembrete manual",
|
||||||
|
"NO_REMINDERS": "Nenhum lembrete para esta caixa de entrada",
|
||||||
|
"LOAD_MORE": "Carregar mais"
|
||||||
|
},
|
||||||
|
"SECTION_TITLE": "Lembrete",
|
||||||
|
"LIST_TITLE": "Lembretes agendados",
|
||||||
|
"FORM": {
|
||||||
|
"MESSAGE_LABEL": "Mensagem",
|
||||||
|
"MESSAGE_PLACEHOLDER": "Sua suite esta disponivel...",
|
||||||
|
"TIME_LABEL": "Enviar em",
|
||||||
|
"SUBMIT": "Agendar lembrete"
|
||||||
|
},
|
||||||
|
"SUCCESS": {
|
||||||
|
"CREATED": "Lembrete agendado",
|
||||||
|
"CANCELLED": "Lembrete cancelado"
|
||||||
|
},
|
||||||
|
"ERRORS": {
|
||||||
|
"LOAD_FAILED": "Nao foi possivel carregar os lembretes",
|
||||||
|
"MISSING_FIELDS": "Preencha mensagem e horario",
|
||||||
|
"PHONE_REQUIRED": "Telefone obrigatorio",
|
||||||
|
"CREATE_FAILED": "Nao foi possivel agendar o lembrete",
|
||||||
|
"CANCEL_FAILED": "Nao foi possivel cancelar o lembrete"
|
||||||
|
}
|
||||||
|
},
|
||||||
"CUSTOM_TOOLS": {
|
"CUSTOM_TOOLS": {
|
||||||
"HEADER": "Poderes",
|
"HEADER": "Poderes",
|
||||||
"ADD_NEW": "Criar poder",
|
"ADD_NEW": "Criar poder",
|
||||||
@ -926,8 +1141,21 @@
|
|||||||
"LABEL": "Response Template (Optional)",
|
"LABEL": "Response Template (Optional)",
|
||||||
"PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}"
|
"PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}"
|
||||||
},
|
},
|
||||||
|
"HEADERS": {
|
||||||
|
"LABEL": "Headers Personalizados",
|
||||||
|
"HELP_TEXT": "Defina cabeçalhos estáticos para autenticação e segurança (Ex: PLUG-PLAY-TOKEN)."
|
||||||
|
},
|
||||||
|
"ADD_HEADER": "Adicionar Header",
|
||||||
|
"HEADER_KEY": {
|
||||||
|
"PLACEHOLDER": "Nome do Header (Ex: X-Auth-Token)"
|
||||||
|
},
|
||||||
|
"HEADER_VALUE": {
|
||||||
|
"PLACEHOLDER": "Valor"
|
||||||
|
},
|
||||||
"ERRORS": {
|
"ERRORS": {
|
||||||
"PARAM_NAME_REQUIRED": "Parameter name is required"
|
"PARAM_NAME_REQUIRED": "Nome do parâmetro obrigatório",
|
||||||
|
"HEADER_KEY_REQUIRED": "Nome do header obrigatório",
|
||||||
|
"HEADER_VALUE_REQUIRED": "Valor do header obrigatório"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -1020,6 +1248,12 @@
|
|||||||
"INBOXES": {
|
"INBOXES": {
|
||||||
"HEADER": "Caixas de entrada conectadas",
|
"HEADER": "Caixas de entrada conectadas",
|
||||||
"ADD_NEW": "Conectar uma nova caixa de entrada",
|
"ADD_NEW": "Conectar uma nova caixa de entrada",
|
||||||
|
"REMINDER_TOOL": {
|
||||||
|
"LABEL": "Sempre usar a ferramenta de lembretes",
|
||||||
|
"HELP": "Quando ativo, o assistente deve agendar lembretes usando a ferramenta.",
|
||||||
|
"SUCCESS": "Preferencia de lembretes atualizada",
|
||||||
|
"ERROR": "Nao foi possivel atualizar a preferencia"
|
||||||
|
},
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
"DISCONNECT": "Desconectar"
|
"DISCONNECT": "Desconectar"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -312,6 +312,8 @@
|
|||||||
"CAPTAIN": "Capitão",
|
"CAPTAIN": "Capitão",
|
||||||
"CAPTAIN_ASSISTANTS": "Assistentes",
|
"CAPTAIN_ASSISTANTS": "Assistentes",
|
||||||
"CAPTAIN_DOCUMENTS": "Documentos",
|
"CAPTAIN_DOCUMENTS": "Documentos",
|
||||||
|
"CAPTAIN_ASSETS": "Galeria de Midia",
|
||||||
|
"CAPTAIN_RESERVATIONS": "Reservas",
|
||||||
"CAPTAIN_RESPONSES": "FAQs",
|
"CAPTAIN_RESPONSES": "FAQs",
|
||||||
"CAPTAIN_TOOLS": "Poderes",
|
"CAPTAIN_TOOLS": "Poderes",
|
||||||
"CAPTAIN_SCENARIOS": "Cenários",
|
"CAPTAIN_SCENARIOS": "Cenários",
|
||||||
|
|||||||
@ -0,0 +1,143 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||||
|
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
|
||||||
|
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||||
|
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||||
|
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||||
|
import AssetCard from 'dashboard/components-next/captain/pageComponents/asset/AssetCard.vue';
|
||||||
|
import CreateAssetDialog from 'dashboard/components-next/captain/pageComponents/asset/CreateAssetDialog.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import EditAssetDialog from 'dashboard/components-next/captain/pageComponents/asset/EditAssetDialog.vue';
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const uiFlags = useMapGetter('captainAssets/getUIFlags');
|
||||||
|
const assets = useMapGetter('captainAssets/getRecords');
|
||||||
|
const assetsMeta = useMapGetter('captainAssets/getMeta');
|
||||||
|
|
||||||
|
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||||
|
|
||||||
|
const selectedAsset = ref(null);
|
||||||
|
const deleteAssetDialog = ref(null);
|
||||||
|
const showCreateDialog = ref(false);
|
||||||
|
const createAssetDialog = ref(null);
|
||||||
|
const showEditDialog = ref(false);
|
||||||
|
const editAssetDialog = ref(null);
|
||||||
|
|
||||||
|
const fetchAssets = (page = 1) => {
|
||||||
|
store.dispatch('captainAssets/get', { page });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateAsset = () => {
|
||||||
|
showCreateDialog.value = true;
|
||||||
|
nextTick(() => createAssetDialog.value.dialogRef.open());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateDialogClose = () => {
|
||||||
|
showCreateDialog.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditDialogClose = () => {
|
||||||
|
showEditDialog.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
deleteAssetDialog.value.dialogRef.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAction = ({ action, id }) => {
|
||||||
|
selectedAsset.value = assets.value.find(asset => id === asset.id);
|
||||||
|
if (action === 'delete') {
|
||||||
|
handleDelete();
|
||||||
|
} else if (action === 'edit') {
|
||||||
|
showEditDialog.value = true;
|
||||||
|
nextTick(() => editAssetDialog.value.dialogRef.open());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeleteSuccess = () => {
|
||||||
|
if (assets.value?.length === 0 && assetsMeta.value?.page > 1) {
|
||||||
|
fetchAssets(assetsMeta.value.page - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPageChange = page => fetchAssets(page);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchAssets();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
|
<PageLayout
|
||||||
|
:header-title="$t('CAPTAIN.ASSETS.HEADER')"
|
||||||
|
:header-description="$t('CAPTAIN.ASSETS.DESCRIPTION')"
|
||||||
|
:button-label="$t('CAPTAIN.ASSETS.ADD_NEW')"
|
||||||
|
:button-policy="['administrator']"
|
||||||
|
:total-count="assetsMeta.totalCount"
|
||||||
|
:current-page="assetsMeta.page"
|
||||||
|
:show-pagination-footer="!isFetching && !!assets.length"
|
||||||
|
:is-fetching="isFetching"
|
||||||
|
:is-empty="!assets.length"
|
||||||
|
:show-know-more="false"
|
||||||
|
:show-assistant-switcher="false"
|
||||||
|
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||||
|
@update:current-page="onPageChange"
|
||||||
|
@click="handleCreateAsset"
|
||||||
|
>
|
||||||
|
<template #paywall>
|
||||||
|
<CaptainPaywall />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #emptyState>
|
||||||
|
<div class="flex flex-col items-center gap-3 py-8 text-center">
|
||||||
|
<i class="text-3xl i-ph-image text-n-slate-11" />
|
||||||
|
<p class="text-sm text-n-slate-11">
|
||||||
|
{{ $t('CAPTAIN.ASSETS.EMPTY_STATE') }}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
:label="$t('CAPTAIN.ASSETS.ADD_NEW')"
|
||||||
|
icon="i-lucide-plus"
|
||||||
|
size="sm"
|
||||||
|
@click="handleCreateAsset"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<AssetCard
|
||||||
|
v-for="asset in assets"
|
||||||
|
:id="asset.id"
|
||||||
|
:key="asset.id"
|
||||||
|
:name="asset.name"
|
||||||
|
:file-url="asset.file_url"
|
||||||
|
:created-at="asset.created_at"
|
||||||
|
@action="handleAction"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<CreateAssetDialog
|
||||||
|
v-if="showCreateDialog"
|
||||||
|
ref="createAssetDialog"
|
||||||
|
@close="handleCreateDialogClose"
|
||||||
|
/>
|
||||||
|
<EditAssetDialog
|
||||||
|
v-if="showEditDialog && selectedAsset"
|
||||||
|
ref="editAssetDialog"
|
||||||
|
:asset="selectedAsset"
|
||||||
|
@close="handleEditDialogClose"
|
||||||
|
/>
|
||||||
|
<DeleteDialog
|
||||||
|
v-if="selectedAsset"
|
||||||
|
ref="deleteAssetDialog"
|
||||||
|
type="Assets"
|
||||||
|
translation-key="ASSETS"
|
||||||
|
:entity="selectedAsset"
|
||||||
|
@delete-success="onDeleteSuccess"
|
||||||
|
/>
|
||||||
|
</PageLayout>
|
||||||
|
</template>
|
||||||
@ -46,6 +46,7 @@ const handleAfterCreate = newAssistant => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<PageLayout
|
<PageLayout
|
||||||
:header-title="$t('CAPTAIN.ASSISTANTS.HEADER')"
|
:header-title="$t('CAPTAIN.ASSISTANTS.HEADER')"
|
||||||
:show-pagination-footer="false"
|
:show-pagination-footer="false"
|
||||||
|
|||||||
@ -173,6 +173,7 @@ const addAllExample = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<PageLayout
|
<PageLayout
|
||||||
:header-title="$t('CAPTAIN.ASSISTANTS.GUARDRAILS.TITLE')"
|
:header-title="$t('CAPTAIN.ASSISTANTS.GUARDRAILS.TITLE')"
|
||||||
:is-fetching="isFetching"
|
:is-fetching="isFetching"
|
||||||
|
|||||||
@ -180,6 +180,7 @@ const addAllExample = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<PageLayout
|
<PageLayout
|
||||||
:header-title="$t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.TITLE')"
|
:header-title="$t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.TITLE')"
|
||||||
:is-fetching="isFetching"
|
:is-fetching="isFetching"
|
||||||
|
|||||||
@ -57,6 +57,7 @@ onMounted(() =>
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<PageLayout
|
<PageLayout
|
||||||
:header-title="$t('CAPTAIN.INBOXES.HEADER')"
|
:header-title="$t('CAPTAIN.INBOXES.HEADER')"
|
||||||
:button-label="$t('CAPTAIN.INBOXES.ADD_NEW')"
|
:button-label="$t('CAPTAIN.INBOXES.ADD_NEW')"
|
||||||
@ -79,6 +80,7 @@ onMounted(() =>
|
|||||||
:id="captainInbox.id"
|
:id="captainInbox.id"
|
||||||
:key="captainInbox.id"
|
:key="captainInbox.id"
|
||||||
:inbox="captainInbox"
|
:inbox="captainInbox"
|
||||||
|
:assistant-id="assistantId"
|
||||||
@action="handleAction"
|
@action="handleAction"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ const assistantId = computed(() => Number(route.params.assistantId));
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<PageLayout
|
<PageLayout
|
||||||
show-assistant-switcher
|
show-assistant-switcher
|
||||||
:show-pagination-footer="false"
|
:show-pagination-footer="false"
|
||||||
|
|||||||
@ -198,6 +198,7 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<PageLayout
|
<PageLayout
|
||||||
:header-title="$t('CAPTAIN.DOCUMENTS.HEADER')"
|
:header-title="$t('CAPTAIN.DOCUMENTS.HEADER')"
|
||||||
:is-fetching="isFetching"
|
:is-fetching="isFetching"
|
||||||
@ -241,7 +242,7 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
<span class="text-sm text-n-slate-11 font-medium mb-1">
|
<span class="text-sm text-n-slate-11 font-medium mb-1">
|
||||||
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }}
|
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }}
|
||||||
{{ item.tools?.map(tool => `@${tool}`).join(', ')}}
|
{{ item.tools?.map(tool => `@${tool}`).join(', ') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -307,4 +308,4 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
|||||||
import SettingsHeader from 'dashboard/components-next/captain/pageComponents/settings/SettingsHeader.vue';
|
import SettingsHeader from 'dashboard/components-next/captain/pageComponents/settings/SettingsHeader.vue';
|
||||||
import AssistantBasicSettingsForm from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantBasicSettingsForm.vue';
|
import AssistantBasicSettingsForm from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantBasicSettingsForm.vue';
|
||||||
import AssistantSystemSettingsForm from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue';
|
import AssistantSystemSettingsForm from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue';
|
||||||
|
import AssistantWebhookSettings from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantWebhookSettings.vue';
|
||||||
import AssistantControlItems from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantControlItems.vue';
|
import AssistantControlItems from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantControlItems.vue';
|
||||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||||
|
|
||||||
@ -57,7 +58,8 @@ const controlItems = computed(() => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Assistant Skills',
|
name: 'Assistant Skills',
|
||||||
description: 'Configure external tools and integrations available to this assistant.',
|
description:
|
||||||
|
'Configure external tools and integrations available to this assistant.',
|
||||||
routeName: 'captain_tools_index',
|
routeName: 'captain_tools_index',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -108,6 +110,7 @@ const handleDeleteSuccess = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<PageLayout
|
<PageLayout
|
||||||
:is-fetching="isFetching"
|
:is-fetching="isFetching"
|
||||||
:show-pagination-footer="false"
|
:show-pagination-footer="false"
|
||||||
@ -149,6 +152,13 @@ const handleDeleteSuccess = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="h-px w-full bg-n-weak mt-2" />
|
<span class="h-px w-full bg-n-weak mt-2" />
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<AssistantWebhookSettings
|
||||||
|
:assistant="assistant"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="h-px w-full bg-n-weak mt-2" />
|
||||||
<div class="flex items-end justify-between w-full gap-4">
|
<div class="flex items-end justify-between w-full gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<h6 class="text-n-slate-12 text-base font-medium">
|
<h6 class="text-n-slate-12 text-base font-medium">
|
||||||
|
|||||||
@ -117,6 +117,7 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<PageLayout
|
<PageLayout
|
||||||
:header-title="$t('CAPTAIN.ASSISTANTS.SKILLS.HEADER')"
|
:header-title="$t('CAPTAIN.ASSISTANTS.SKILLS.HEADER')"
|
||||||
:header-description="$t('CAPTAIN.ASSISTANTS.SKILLS.DESCRIPTION')"
|
:header-description="$t('CAPTAIN.ASSISTANTS.SKILLS.DESCRIPTION')"
|
||||||
@ -152,7 +153,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="tool.enabled"
|
v-if="tool.enabled && tool.key !== 'react_to_message'"
|
||||||
class="flex flex-col gap-4 pl-4 border-l-2 border-n-weak mt-6 pt-2 transition-all"
|
class="flex flex-col gap-4 pl-4 border-l-2 border-n-weak mt-6 pt-2 transition-all"
|
||||||
>
|
>
|
||||||
<h5
|
<h5
|
||||||
|
|||||||
@ -0,0 +1,204 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import Modal from 'dashboard/components/Modal.vue';
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
brand: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'save']);
|
||||||
|
const { t } = useI18n(); // eslint-disable-line no-unused-vars
|
||||||
|
|
||||||
|
const name = ref('');
|
||||||
|
// suiteItems will hold objects: { name: 'Standard', image: 'url' }
|
||||||
|
const suiteItems = ref([]);
|
||||||
|
const stayDurations = ref('');
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
name.value = '';
|
||||||
|
suiteItems.value = [{ name: '', image: '' }];
|
||||||
|
stayDurations.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.brand,
|
||||||
|
newBrand => {
|
||||||
|
if (newBrand) {
|
||||||
|
name.value = newBrand.name;
|
||||||
|
// Parse suite categories and images (handle both snake_case and camelCase)
|
||||||
|
const categories =
|
||||||
|
newBrand.suite_categories || newBrand.suiteCategories || [];
|
||||||
|
const images = newBrand.suite_images || newBrand.suiteImages || {};
|
||||||
|
|
||||||
|
if (Array.isArray(categories) && categories.length > 0) {
|
||||||
|
suiteItems.value = categories.map(cat => ({
|
||||||
|
name: cat,
|
||||||
|
image: images[cat] || '',
|
||||||
|
}));
|
||||||
|
} else if (typeof categories === 'string') {
|
||||||
|
// Handle legacy string format if exists
|
||||||
|
suiteItems.value = categories
|
||||||
|
.split(',')
|
||||||
|
.map(s => ({ name: s.trim(), image: '' }));
|
||||||
|
} else {
|
||||||
|
suiteItems.value = [{ name: '', image: '' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const durations = newBrand.stay_durations || newBrand.stayDurations;
|
||||||
|
stayDurations.value = Array.isArray(durations)
|
||||||
|
? durations.join(', ')
|
||||||
|
: durations || '';
|
||||||
|
} else {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const addSuiteItem = () => {
|
||||||
|
suiteItems.value.push({ name: '', image: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSuiteItem = index => {
|
||||||
|
suiteItems.value.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
emit('close');
|
||||||
|
resetForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSave = () => {
|
||||||
|
// Convert suiteItems back to separate structures
|
||||||
|
const validItems = suiteItems.value.filter(item => item.name.trim() !== '');
|
||||||
|
const categories = validItems.map(item => item.name.trim());
|
||||||
|
|
||||||
|
const images = {};
|
||||||
|
validItems.forEach(item => {
|
||||||
|
if (item.image && item.image.trim() !== '') {
|
||||||
|
images[item.name.trim()] = item.image.trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: name.value,
|
||||||
|
suite_categories: categories,
|
||||||
|
suite_images: images,
|
||||||
|
stay_durations: stayDurations.value
|
||||||
|
.split(',')
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(s => s),
|
||||||
|
};
|
||||||
|
emit('save', payload);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerTitle = computed(() =>
|
||||||
|
props.brand ? 'Editar Marca' : 'Nova Marca'
|
||||||
|
);
|
||||||
|
const saveLabel = computed(() => (props.brand ? 'Atualizar' : 'Criar'));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
|
<Modal :show="show" :on-close="onClose">
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-4 p-6 w-[600px] bg-white dark:bg-slate-900 rounded-lg"
|
||||||
|
>
|
||||||
|
<h2 class="text-xl font-semibold text-slate-800 dark:text-slate-100">
|
||||||
|
{{ headerTitle }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-1"
|
||||||
|
>
|
||||||
|
Nome da Marca
|
||||||
|
</label>
|
||||||
|
<Input v-model="name" placeholder="Ex: Hotel Prime" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-1"
|
||||||
|
>
|
||||||
|
Categorias de Suíte e Fotos
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-2 max-h-[300px] overflow-y-auto pr-2 mb-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in suiteItems"
|
||||||
|
:key="index"
|
||||||
|
class="flex gap-2 items-start"
|
||||||
|
>
|
||||||
|
<div class="flex-1">
|
||||||
|
<Input
|
||||||
|
v-model="item.name"
|
||||||
|
placeholder="Nome (Ex: Presidencial)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<Input
|
||||||
|
v-model="item.image"
|
||||||
|
placeholder="URL da Imagem (https://...)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="mt-2 text-red-500 hover:text-red-700 p-1"
|
||||||
|
title="Remover"
|
||||||
|
@click="removeSuiteItem(index)"
|
||||||
|
>
|
||||||
|
<i class="i-lucide-trash-2" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="text-sm text-blue-600 hover:text-blue-800 flex items-center gap-1 font-medium bg-transparent border-none p-0 cursor-pointer"
|
||||||
|
@click="addSuiteItem"
|
||||||
|
>
|
||||||
|
<i class="i-lucide-plus" /> Adicionar Categoria
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="text-xs text-slate-500 mt-2 dark:text-slate-400">
|
||||||
|
Insira o nome da categoria e opcionalmente a URL da foto.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-1"
|
||||||
|
>
|
||||||
|
Permanências (separadas por vírgula)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
v-model="stayDurations"
|
||||||
|
placeholder="Ex: 2h, 4h, Pernoite, Diária"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex justify-end gap-2 mt-4 pt-4 border-t border-slate-100 dark:border-slate-800"
|
||||||
|
>
|
||||||
|
<Button variant="ghost" @click="onClose"> Cancelar </Button>
|
||||||
|
<Button @click="onSave">
|
||||||
|
{{ saveLabel }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,242 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import BrandModal from './BrandModal.vue';
|
||||||
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const accountId = route.params.accountId;
|
||||||
|
|
||||||
|
const brands = ref([]);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const showModal = ref(false);
|
||||||
|
const selectedBrand = ref(null);
|
||||||
|
|
||||||
|
const fetchBrands = async () => {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await window.axios.get(
|
||||||
|
`/api/v1/accounts/${accountId}/captain/brands`
|
||||||
|
);
|
||||||
|
brands.value = response.data;
|
||||||
|
} catch (error) {
|
||||||
|
useAlert('Erro ao buscar marcas');
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAddModal = () => {
|
||||||
|
selectedBrand.value = null;
|
||||||
|
showModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = brand => {
|
||||||
|
selectedBrand.value = brand;
|
||||||
|
showModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteBrand = async brandId => {
|
||||||
|
// eslint-disable-next-line no-alert, no-restricted-globals
|
||||||
|
if (!confirm('Tem certeza que deseja excluir esta marca?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.axios.delete(
|
||||||
|
`/api/v1/accounts/${accountId}/captain/brands/${brandId}`
|
||||||
|
);
|
||||||
|
brands.value = brands.value.filter(b => b.id !== brandId);
|
||||||
|
useAlert('Marca excluída com sucesso');
|
||||||
|
} catch (error) {
|
||||||
|
useAlert('Erro ao excluir marca');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async brandData => {
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (selectedBrand.value) {
|
||||||
|
// Update existing brand
|
||||||
|
response = await window.axios.put(
|
||||||
|
`/api/v1/accounts/${accountId}/captain/brands/${selectedBrand.value.id}`,
|
||||||
|
{ brand: brandData }
|
||||||
|
);
|
||||||
|
const index = brands.value.findIndex(
|
||||||
|
b => b.id === selectedBrand.value.id
|
||||||
|
);
|
||||||
|
if (index !== -1) {
|
||||||
|
brands.value[index] = response.data;
|
||||||
|
}
|
||||||
|
useAlert('Marca atualizada com sucesso');
|
||||||
|
} else {
|
||||||
|
// Create new brand
|
||||||
|
response = await window.axios.post(
|
||||||
|
`/api/v1/accounts/${accountId}/captain/brands`,
|
||||||
|
{ brand: brandData }
|
||||||
|
);
|
||||||
|
brands.value.push(response.data);
|
||||||
|
useAlert('Marca criada com sucesso');
|
||||||
|
}
|
||||||
|
showModal.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
useAlert('Erro ao salvar marca');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const joinList = list => {
|
||||||
|
if (!list) return '';
|
||||||
|
return list.join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(fetchBrands);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
|
<div
|
||||||
|
class="flex flex-col h-full w-full bg-slate-50 dark:bg-slate-900 px-8 py-8 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div class="flex-1 w-full">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-2xl font-semibold text-slate-800 dark:text-slate-100">
|
||||||
|
Painel Administrativo
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 w-full"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="p-6 border-b border-slate-200 dark:border-slate-700 flex justify-between items-center bg-white dark:bg-slate-800 rounded-t-lg"
|
||||||
|
>
|
||||||
|
<h2 class="text-lg font-medium text-slate-800 dark:text-slate-100">
|
||||||
|
Gerenciar Marcas
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
variant="smooth"
|
||||||
|
size="sm"
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
@click="openAddModal"
|
||||||
|
>
|
||||||
|
<i class="i-lucide-plus" />
|
||||||
|
Adicionar Nova Marca
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="p-8 flex justify-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="overflow-x-auto">
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead
|
||||||
|
class="bg-slate-50 dark:bg-slate-700/50 text-slate-500 dark:text-slate-300 uppercase font-medium"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-4 w-1/4">Nome</th>
|
||||||
|
<th class="px-6 py-4 w-1/3">Categorias</th>
|
||||||
|
<th class="px-6 py-4 w-1/4">Permanências</th>
|
||||||
|
<th class="px-6 py-4 text-right">Ações</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||||
|
<tr
|
||||||
|
v-for="brand in brands"
|
||||||
|
:key="brand.id"
|
||||||
|
class="hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
class="px-6 py-4 font-medium text-slate-900 dark:text-slate-100 align-top"
|
||||||
|
>
|
||||||
|
{{ brand.name }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="px-6 py-4 text-slate-600 dark:text-slate-300 align-top"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div
|
||||||
|
v-for="(cat, idx) in brand.suiteCategories ||
|
||||||
|
brand.suite_categories ||
|
||||||
|
[]"
|
||||||
|
:key="idx"
|
||||||
|
class="break-words border-b border-slate-100 dark:border-slate-700/50 last:border-0 pb-1 last:pb-0"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{{ cat }}</span>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
(brand.suiteImages || brand.suite_images) &&
|
||||||
|
(brand.suiteImages || brand.suite_images)[cat]
|
||||||
|
"
|
||||||
|
class="text-xs text-blue-500 mt-0.5 truncate max-w-[300px]"
|
||||||
|
:title="(brand.suiteImages || brand.suite_images)[cat]"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
:href="(brand.suiteImages || brand.suite_images)[cat]"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<i class="i-lucide-link size-3" />
|
||||||
|
Ver Imagem
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="px-6 py-4 text-slate-600 dark:text-slate-300 align-top"
|
||||||
|
>
|
||||||
|
{{ joinList(brand.stayDurations || brand.stay_durations) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-right align-top">
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
class="text-blue-600 hover:text-blue-800 font-medium text-sm"
|
||||||
|
@click="openEditModal(brand)"
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="text-red-500 hover:text-red-700 font-medium text-sm"
|
||||||
|
@click="deleteBrand(brand.id)"
|
||||||
|
>
|
||||||
|
Excluir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="brands.length === 0">
|
||||||
|
<td
|
||||||
|
colspan="4"
|
||||||
|
class="px-6 py-12 text-center text-slate-500 border-t border-slate-200 dark:border-slate-700"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<i
|
||||||
|
class="i-lucide-building-2 text-4xl text-slate-300 mb-2"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
class="text-base font-medium text-slate-900 dark:text-slate-100"
|
||||||
|
>
|
||||||
|
Nenhuma marca cadastrada
|
||||||
|
</p>
|
||||||
|
<p class="text-sm">
|
||||||
|
Clique no botão acima para adicionar a primeira marca.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BrandModal
|
||||||
|
:show="showModal"
|
||||||
|
:brand="selectedBrand"
|
||||||
|
@close="showModal = false"
|
||||||
|
@save="handleSave"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user