Merge branch 'develop' into fix/message-contains
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal.vue';
|
||||
import AddAccountModal from './components/app/AddAccountModal.vue';
|
||||
import LoadingState from './components/widgets/LoadingState.vue';
|
||||
import NetworkNotification from './components/NetworkNotification.vue';
|
||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
verifyServiceWorkerExistence,
|
||||
} from './helper/pushHelper';
|
||||
import ReconnectService from 'dashboard/helper/ReconnectService';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
@@ -38,12 +39,14 @@ export default {
|
||||
const { accountId } = useAccount();
|
||||
// Use the font size composable (it automatically sets up the watcher)
|
||||
const { currentFontSize } = useFontSize();
|
||||
const { uiSettings } = useUISettings();
|
||||
|
||||
return {
|
||||
router,
|
||||
store,
|
||||
currentAccountId: accountId,
|
||||
currentFontSize,
|
||||
uiSettings,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -88,7 +91,10 @@ export default {
|
||||
mounted() {
|
||||
this.initializeColorTheme();
|
||||
this.listenToThemeChanges();
|
||||
this.setLocale(window.chatwootConfig.selectedLocale);
|
||||
// If user locale is set, use it; otherwise use account locale
|
||||
this.setLocale(
|
||||
this.uiSettings?.locale || window.chatwootConfig.selectedLocale
|
||||
);
|
||||
},
|
||||
unmounted() {
|
||||
if (this.reconnectService) {
|
||||
@@ -114,7 +120,8 @@ export default {
|
||||
const { locale, latest_chatwoot_version: latestChatwootVersion } =
|
||||
this.getAccount(this.currentAccountId);
|
||||
const { pubsub_token: pubsubToken } = this.currentUser || {};
|
||||
this.setLocale(locale);
|
||||
// If user locale is set, use it; otherwise use account locale
|
||||
this.setLocale(this.uiSettings?.locale || locale);
|
||||
this.latestChatwootVersion = latestChatwootVersion;
|
||||
vueActionCable.init(this.store, pubsubToken);
|
||||
this.reconnectService = new ReconnectService(this.store, this.router);
|
||||
@@ -136,8 +143,7 @@ export default {
|
||||
<div
|
||||
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
|
||||
id="app"
|
||||
class="flex-grow-0 w-full h-full min-h-0 app-wrapper"
|
||||
:class="{ 'app-rtl--wrapper': isRTL }"
|
||||
class="flex flex-col w-full h-screen min-h-0"
|
||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||
>
|
||||
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
|
||||
|
||||
@@ -21,6 +21,10 @@ class AgentBotsAPI extends ApiClient {
|
||||
deleteAgentBotAvatar(botId) {
|
||||
return axios.delete(`${this.url}/${botId}/avatar`);
|
||||
}
|
||||
|
||||
resetAccessToken(botId) {
|
||||
return axios.post(`${this.url}/${botId}/reset_access_token`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new AgentBotsAPI();
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/* global axios */
|
||||
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class AgentCapacityPolicies extends ApiClient {
|
||||
constructor() {
|
||||
super('agent_capacity_policies', { accountScoped: true });
|
||||
}
|
||||
|
||||
getUsers(policyId) {
|
||||
return axios.get(`${this.url}/${policyId}/users`);
|
||||
}
|
||||
|
||||
addUser(policyId, userData) {
|
||||
return axios.post(`${this.url}/${policyId}/users`, {
|
||||
user_id: userData.id,
|
||||
capacity: userData.capacity,
|
||||
});
|
||||
}
|
||||
|
||||
removeUser(policyId, userId) {
|
||||
return axios.delete(`${this.url}/${policyId}/users/${userId}`);
|
||||
}
|
||||
|
||||
createInboxLimit(policyId, limitData) {
|
||||
return axios.post(`${this.url}/${policyId}/inbox_limits`, {
|
||||
inbox_id: limitData.inboxId,
|
||||
conversation_limit: limitData.conversationLimit,
|
||||
});
|
||||
}
|
||||
|
||||
updateInboxLimit(policyId, limitId, limitData) {
|
||||
return axios.put(`${this.url}/${policyId}/inbox_limits/${limitId}`, {
|
||||
conversation_limit: limitData.conversationLimit,
|
||||
});
|
||||
}
|
||||
|
||||
deleteInboxLimit(policyId, limitId) {
|
||||
return axios.delete(`${this.url}/${policyId}/inbox_limits/${limitId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new AgentCapacityPolicies();
|
||||
@@ -0,0 +1,36 @@
|
||||
/* global axios */
|
||||
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class AssignmentPolicies extends ApiClient {
|
||||
constructor() {
|
||||
super('assignment_policies', { accountScoped: true });
|
||||
}
|
||||
|
||||
getInboxes(policyId) {
|
||||
return axios.get(`${this.url}/${policyId}/inboxes`);
|
||||
}
|
||||
|
||||
setInboxPolicy(inboxId, policyId) {
|
||||
return axios.post(
|
||||
`/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy`,
|
||||
{
|
||||
assignment_policy_id: policyId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getInboxPolicy(inboxId) {
|
||||
return axios.get(
|
||||
`/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy`
|
||||
);
|
||||
}
|
||||
|
||||
removeInboxPolicy(inboxId) {
|
||||
return axios.delete(
|
||||
`/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new AssignmentPolicies();
|
||||
@@ -38,13 +38,7 @@ export default {
|
||||
}
|
||||
return false;
|
||||
},
|
||||
profileUpdate({
|
||||
password,
|
||||
password_confirmation,
|
||||
displayName,
|
||||
avatar,
|
||||
...profileAttributes
|
||||
}) {
|
||||
profileUpdate({ displayName, avatar, ...profileAttributes }) {
|
||||
const formData = new FormData();
|
||||
Object.keys(profileAttributes).forEach(key => {
|
||||
const hasValue = profileAttributes[key] === undefined;
|
||||
@@ -53,16 +47,22 @@ export default {
|
||||
}
|
||||
});
|
||||
formData.append('profile[display_name]', displayName || '');
|
||||
if (password && password_confirmation) {
|
||||
formData.append('profile[password]', password);
|
||||
formData.append('profile[password_confirmation]', password_confirmation);
|
||||
}
|
||||
if (avatar) {
|
||||
formData.append('profile[avatar]', avatar);
|
||||
}
|
||||
return axios.put(endPoints('profileUpdate').url, formData);
|
||||
},
|
||||
|
||||
profilePasswordUpdate({ currentPassword, password, passwordConfirmation }) {
|
||||
return axios.put(endPoints('profileUpdate').url, {
|
||||
profile: {
|
||||
current_password: currentPassword,
|
||||
password,
|
||||
password_confirmation: passwordConfirmation,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
updateUISettings({ uiSettings }) {
|
||||
return axios.put(endPoints('profileUpdate').url, {
|
||||
profile: { ui_settings: uiSettings },
|
||||
@@ -102,4 +102,8 @@ export default {
|
||||
const urlData = endPoints('resendConfirmation');
|
||||
return axios.post(urlData.url);
|
||||
},
|
||||
resetAccessToken() {
|
||||
const urlData = endPoints('resetAccessToken');
|
||||
return axios.post(urlData.url);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -14,6 +14,13 @@ class CaptainAssistant extends ApiClient {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
playground({ assistantId, messageContent, messageHistory }) {
|
||||
return axios.post(`${this.url}/${assistantId}/playground`, {
|
||||
message_content: messageContent,
|
||||
message_history: messageHistory,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainAssistant();
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CopilotMessages extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/copilot_threads', { accountScoped: true });
|
||||
}
|
||||
|
||||
get(threadId) {
|
||||
return axios.get(`${this.url}/${threadId}/copilot_messages`);
|
||||
}
|
||||
|
||||
create({ threadId, ...rest }) {
|
||||
return axios.post(`${this.url}/${threadId}/copilot_messages`, rest);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CopilotMessages();
|
||||
@@ -0,0 +1,9 @@
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CopilotThreads extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/copilot_threads', { accountScoped: true });
|
||||
}
|
||||
}
|
||||
|
||||
export default new CopilotThreads();
|
||||
@@ -6,11 +6,11 @@ class CaptainResponses extends ApiClient {
|
||||
super('captain/assistant_responses', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ page = 1, searchKey, assistantId, documentId, status } = {}) {
|
||||
get({ page = 1, search, assistantId, documentId, status } = {}) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
page,
|
||||
searchKey,
|
||||
search,
|
||||
assistant_id: assistantId,
|
||||
document_id: documentId,
|
||||
status,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CaptainScenarios extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/assistants', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ assistantId, page = 1, searchKey } = {}) {
|
||||
return axios.get(`${this.url}/${assistantId}/scenarios`, {
|
||||
params: { page, searchKey },
|
||||
});
|
||||
}
|
||||
|
||||
show({ assistantId, id }) {
|
||||
return axios.get(`${this.url}/${assistantId}/scenarios/${id}`);
|
||||
}
|
||||
|
||||
create({ assistantId, ...data } = {}) {
|
||||
return axios.post(`${this.url}/${assistantId}/scenarios`, {
|
||||
scenario: data,
|
||||
});
|
||||
}
|
||||
|
||||
update({ assistantId, id }, data = {}) {
|
||||
return axios.put(`${this.url}/${assistantId}/scenarios/${id}`, {
|
||||
scenario: data,
|
||||
});
|
||||
}
|
||||
|
||||
delete({ assistantId, id }) {
|
||||
return axios.delete(`${this.url}/${assistantId}/scenarios/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainScenarios();
|
||||
@@ -0,0 +1,16 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CaptainTools extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/assistants/tools', { accountScoped: true });
|
||||
}
|
||||
|
||||
get(params = {}) {
|
||||
return axios.get(this.url, {
|
||||
params,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainTools();
|
||||
@@ -0,0 +1,21 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class WhatsappChannel extends ApiClient {
|
||||
constructor() {
|
||||
super('whatsapp', { accountScoped: true });
|
||||
}
|
||||
|
||||
createEmbeddedSignup(params) {
|
||||
return axios.post(`${this.baseUrl()}/whatsapp/authorization`, params);
|
||||
}
|
||||
|
||||
reauthorizeWhatsApp({ inboxId, ...params }) {
|
||||
return axios.post(`${this.baseUrl()}/whatsapp/authorization`, {
|
||||
...params,
|
||||
inbox_id: inboxId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new WhatsappChannel();
|
||||
@@ -61,6 +61,11 @@ class ContactAPI extends ApiClient {
|
||||
return axios.get(requestURL);
|
||||
}
|
||||
|
||||
active(page = 1, sortAttr = 'name') {
|
||||
let requestURL = `${this.url}/active?${buildContactParams(page, sortAttr)}`;
|
||||
return axios.get(requestURL);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line default-param-last
|
||||
filter(page = 1, sortAttr = 'name', queryPayload) {
|
||||
let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`;
|
||||
|
||||
@@ -51,6 +51,10 @@ const endPoints = {
|
||||
resendConfirmation: {
|
||||
url: '/api/v1/profile/resend_confirmation',
|
||||
},
|
||||
|
||||
resetAccessToken: {
|
||||
url: '/api/v1/profile/reset_access_token',
|
||||
},
|
||||
};
|
||||
|
||||
export default page => {
|
||||
|
||||
@@ -21,6 +21,14 @@ class PortalsAPI extends ApiClient {
|
||||
deleteLogo(portalSlug) {
|
||||
return axios.delete(`${this.url}/${portalSlug}/logo`);
|
||||
}
|
||||
|
||||
sendCnameInstructions(portalSlug, email) {
|
||||
return axios.post(`${this.url}/${portalSlug}/send_instructions`, { email });
|
||||
}
|
||||
|
||||
sslStatus(portalSlug) {
|
||||
return axios.get(`${this.url}/${portalSlug}/ssl_status`);
|
||||
}
|
||||
}
|
||||
|
||||
export default PortalsAPI;
|
||||
|
||||
@@ -134,13 +134,13 @@ class ConversationApi extends ApiClient {
|
||||
return axios.get(`${this.url}/${conversationId}/attachments`);
|
||||
}
|
||||
|
||||
requestCopilot(conversationId, body) {
|
||||
return axios.post(`${this.url}/${conversationId}/copilot`, body);
|
||||
}
|
||||
|
||||
getInboxAssistant(conversationId) {
|
||||
return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
|
||||
}
|
||||
|
||||
delete(conversationId) {
|
||||
return axios.delete(`${this.url}/${conversationId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ConversationApi();
|
||||
|
||||
@@ -28,6 +28,10 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
agent_bot: botId,
|
||||
});
|
||||
}
|
||||
|
||||
syncTemplates(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/sync_templates`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Inboxes();
|
||||
|
||||
@@ -33,9 +33,11 @@ class LinearAPI extends ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
unlinkIssue(linkId) {
|
||||
unlinkIssue(linkId, issueIdentifier, conversationId) {
|
||||
return axios.post(`${this.url}/unlink_issue`, {
|
||||
link_id: linkId,
|
||||
issue_id: issueIdentifier,
|
||||
conversation_id: conversationId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class MfaAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('profile/mfa', { accountScoped: false });
|
||||
}
|
||||
|
||||
enable() {
|
||||
return axios.post(`${this.url}`);
|
||||
}
|
||||
|
||||
verify(otpCode) {
|
||||
return axios.post(`${this.url}/verify`, { otp_code: otpCode });
|
||||
}
|
||||
|
||||
disable(password, otpCode) {
|
||||
return axios.delete(this.url, {
|
||||
data: { password, otp_code: otpCode },
|
||||
});
|
||||
}
|
||||
|
||||
regenerateBackupCodes(otpCode) {
|
||||
return axios.post(`${this.url}/backup_codes`, { otp_code: otpCode });
|
||||
}
|
||||
}
|
||||
|
||||
export default new MfaAPI();
|
||||
@@ -0,0 +1,14 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class NotionOAuthClient extends ApiClient {
|
||||
constructor() {
|
||||
super('notion', { accountScoped: true });
|
||||
}
|
||||
|
||||
generateAuthorization() {
|
||||
return axios.post(`${this.url}/authorization`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new NotionOAuthClient();
|
||||
@@ -0,0 +1,26 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class SamlSettingsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('saml_settings', { accountScoped: true });
|
||||
}
|
||||
|
||||
get() {
|
||||
return axios.get(this.url);
|
||||
}
|
||||
|
||||
create(data) {
|
||||
return axios.post(this.url, { saml_settings: data });
|
||||
}
|
||||
|
||||
update(data) {
|
||||
return axios.put(this.url, { saml_settings: data });
|
||||
}
|
||||
|
||||
delete() {
|
||||
return axios.delete(this.url);
|
||||
}
|
||||
}
|
||||
|
||||
export default new SamlSettingsAPI();
|
||||
@@ -40,6 +40,15 @@ class SearchAPI extends ApiClient {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
articles({ q, page = 1 }) {
|
||||
return axios.get(`${this.url}/articles`, {
|
||||
params: {
|
||||
q,
|
||||
page: page,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new SearchAPI();
|
||||
|
||||
@@ -9,5 +9,6 @@ describe('#AgentBotsAPI', () => {
|
||||
expect(AgentBotsAPI).toHaveProperty('create');
|
||||
expect(AgentBotsAPI).toHaveProperty('update');
|
||||
expect(AgentBotsAPI).toHaveProperty('delete');
|
||||
expect(AgentBotsAPI).toHaveProperty('resetAccessToken');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import agentCapacityPolicies from '../agentCapacityPolicies';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
describe('#AgentCapacityPoliciesAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(agentCapacityPolicies).toBeInstanceOf(ApiClient);
|
||||
expect(agentCapacityPolicies).toHaveProperty('get');
|
||||
expect(agentCapacityPolicies).toHaveProperty('show');
|
||||
expect(agentCapacityPolicies).toHaveProperty('create');
|
||||
expect(agentCapacityPolicies).toHaveProperty('update');
|
||||
expect(agentCapacityPolicies).toHaveProperty('delete');
|
||||
expect(agentCapacityPolicies).toHaveProperty('getUsers');
|
||||
expect(agentCapacityPolicies).toHaveProperty('addUser');
|
||||
expect(agentCapacityPolicies).toHaveProperty('removeUser');
|
||||
expect(agentCapacityPolicies).toHaveProperty('createInboxLimit');
|
||||
expect(agentCapacityPolicies).toHaveProperty('updateInboxLimit');
|
||||
expect(agentCapacityPolicies).toHaveProperty('deleteInboxLimit');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
const originalAxios = window.axios;
|
||||
const axiosMock = {
|
||||
get: vi.fn(() => Promise.resolve()),
|
||||
post: vi.fn(() => Promise.resolve()),
|
||||
put: vi.fn(() => Promise.resolve()),
|
||||
delete: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.axios = axiosMock;
|
||||
// Mock accountIdFromRoute
|
||||
Object.defineProperty(agentCapacityPolicies, 'accountIdFromRoute', {
|
||||
get: () => '1',
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('#getUsers', () => {
|
||||
agentCapacityPolicies.getUsers(123);
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1/agent_capacity_policies/123/users'
|
||||
);
|
||||
});
|
||||
|
||||
it('#addUser', () => {
|
||||
const userData = { id: 456, capacity: 20 };
|
||||
agentCapacityPolicies.addUser(123, userData);
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1/agent_capacity_policies/123/users',
|
||||
{
|
||||
user_id: 456,
|
||||
capacity: 20,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#removeUser', () => {
|
||||
agentCapacityPolicies.removeUser(123, 456);
|
||||
expect(axiosMock.delete).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1/agent_capacity_policies/123/users/456'
|
||||
);
|
||||
});
|
||||
|
||||
it('#createInboxLimit', () => {
|
||||
const limitData = { inboxId: 1, conversationLimit: 10 };
|
||||
agentCapacityPolicies.createInboxLimit(123, limitData);
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits',
|
||||
{
|
||||
inbox_id: 1,
|
||||
conversation_limit: 10,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#updateInboxLimit', () => {
|
||||
const limitData = { conversationLimit: 15 };
|
||||
agentCapacityPolicies.updateInboxLimit(123, 789, limitData);
|
||||
expect(axiosMock.put).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits/789',
|
||||
{
|
||||
conversation_limit: 15,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#deleteInboxLimit', () => {
|
||||
agentCapacityPolicies.deleteInboxLimit(123, 789);
|
||||
expect(axiosMock.delete).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits/789'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import assignmentPolicies from '../assignmentPolicies';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
describe('#AssignmentPoliciesAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(assignmentPolicies).toBeInstanceOf(ApiClient);
|
||||
expect(assignmentPolicies).toHaveProperty('get');
|
||||
expect(assignmentPolicies).toHaveProperty('show');
|
||||
expect(assignmentPolicies).toHaveProperty('create');
|
||||
expect(assignmentPolicies).toHaveProperty('update');
|
||||
expect(assignmentPolicies).toHaveProperty('delete');
|
||||
expect(assignmentPolicies).toHaveProperty('getInboxes');
|
||||
expect(assignmentPolicies).toHaveProperty('setInboxPolicy');
|
||||
expect(assignmentPolicies).toHaveProperty('getInboxPolicy');
|
||||
expect(assignmentPolicies).toHaveProperty('removeInboxPolicy');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
const originalAxios = window.axios;
|
||||
const axiosMock = {
|
||||
get: vi.fn(() => Promise.resolve()),
|
||||
post: vi.fn(() => Promise.resolve()),
|
||||
delete: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.axios = axiosMock;
|
||||
// Mock accountIdFromRoute
|
||||
Object.defineProperty(assignmentPolicies, 'accountIdFromRoute', {
|
||||
get: () => '1',
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('#getInboxes', () => {
|
||||
assignmentPolicies.getInboxes(123);
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1/assignment_policies/123/inboxes'
|
||||
);
|
||||
});
|
||||
|
||||
it('#setInboxPolicy', () => {
|
||||
assignmentPolicies.setInboxPolicy(456, 123);
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1/inboxes/456/assignment_policy',
|
||||
{
|
||||
assignment_policy_id: 123,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#getInboxPolicy', () => {
|
||||
assignmentPolicies.getInboxPolicy(456);
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1/inboxes/456/assignment_policy'
|
||||
);
|
||||
});
|
||||
|
||||
it('#removeInboxPolicy', () => {
|
||||
assignmentPolicies.removeInboxPolicy(456);
|
||||
expect(axiosMock.delete).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1/inboxes/456/assignment_policy'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ describe('#InboxesAPI', () => {
|
||||
expect(inboxesAPI).toHaveProperty('getCampaigns');
|
||||
expect(inboxesAPI).toHaveProperty('getAgentBot');
|
||||
expect(inboxesAPI).toHaveProperty('setAgentBot');
|
||||
expect(inboxesAPI).toHaveProperty('syncTemplates');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
@@ -40,5 +41,12 @@ describe('#InboxesAPI', () => {
|
||||
inboxesAPI.deleteInboxAvatar(2);
|
||||
expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar');
|
||||
});
|
||||
|
||||
it('#syncTemplates', () => {
|
||||
inboxesAPI.syncTemplates(2);
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/inboxes/2/sync_templates'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,6 +91,19 @@ describe('#linearAPI', () => {
|
||||
issueData
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a valid request with conversation_id', () => {
|
||||
const issueData = {
|
||||
title: 'New Issue',
|
||||
description: 'Issue description',
|
||||
conversation_id: 123,
|
||||
};
|
||||
LinearAPIClient.createIssue(issueData);
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/integrations/linear/create_issue',
|
||||
issueData
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('link_issue', () => {
|
||||
@@ -120,6 +133,18 @@ describe('#linearAPI', () => {
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a valid request with title', () => {
|
||||
LinearAPIClient.link_issue(1, 'ENG-123', 'Sample Issue');
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/integrations/linear/link_issue',
|
||||
{
|
||||
issue_id: 'ENG-123',
|
||||
conversation_id: 1,
|
||||
title: 'Sample Issue',
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLinkedIssue', () => {
|
||||
@@ -164,12 +189,26 @@ describe('#linearAPI', () => {
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('creates a valid request', () => {
|
||||
LinearAPIClient.unlinkIssue(1);
|
||||
it('creates a valid request with link_id only', () => {
|
||||
LinearAPIClient.unlinkIssue('link123');
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/integrations/linear/unlink_issue',
|
||||
{
|
||||
link_id: 1,
|
||||
link_id: 'link123',
|
||||
issue_id: undefined,
|
||||
conversation_id: undefined,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a valid request with all parameters', () => {
|
||||
LinearAPIClient.unlinkIssue('link123', 'ENG-456', 789);
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/integrations/linear/unlink_issue',
|
||||
{
|
||||
link_id: 'link123',
|
||||
issue_id: 'ENG-456',
|
||||
conversation_id: 789,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -35,6 +35,16 @@ class SummaryReportsAPI extends ApiClient {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getLabelReports({ since, until, businessHours } = {}) {
|
||||
return axios.get(`${this.url}/label`, {
|
||||
params: {
|
||||
since,
|
||||
until,
|
||||
business_hours: businessHours,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new SummaryReportsAPI();
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
.slide-fade-enter-active {
|
||||
transition: all 0.3s var(--ease-in-cubic);
|
||||
}
|
||||
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.3s var(--ease-out-cubic);
|
||||
}
|
||||
|
||||
.slide-fade-enter,
|
||||
.slide-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(10px);
|
||||
}
|
||||
|
||||
.slide-fade-enter {
|
||||
transform: translateX($space-micro);
|
||||
}
|
||||
|
||||
.slide-fade-leave-to {
|
||||
transform: translateX($space-medium);
|
||||
}
|
||||
|
||||
.conversations-list-enter-active,
|
||||
.conversations-list-leave-active {
|
||||
transition: all 0.25s var(--ease-out-cubic);
|
||||
}
|
||||
|
||||
.conversations-list-enter,
|
||||
.conversations-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX($space-medium);
|
||||
}
|
||||
|
||||
.slide-up-enter-active {
|
||||
transition: all 0.3s var(--ease-in-cubic);
|
||||
}
|
||||
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.3s var(--ease-out-cubic);
|
||||
}
|
||||
|
||||
.slide-up-enter,
|
||||
.slide-up-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-$space-medium);
|
||||
}
|
||||
|
||||
.menu-slide-enter-active,
|
||||
.menu-slide-leave-active {
|
||||
transform: translateY(0);
|
||||
transition:
|
||||
transform 0.25s var(--ease-in-cubic),
|
||||
opacity 0.15s var(--ease-in-cubic);
|
||||
}
|
||||
|
||||
.menu-slide-enter,
|
||||
.menu-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY($space-small);
|
||||
}
|
||||
|
||||
.toast-fade-enter-active {
|
||||
transition: all 0.3s var(--ease-in-sine);
|
||||
}
|
||||
|
||||
.toast-fade-leave-active {
|
||||
transition: all 0.1s var(--ease-out-sine);
|
||||
}
|
||||
|
||||
.toast-fade-enter,
|
||||
.toast-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-$space-small);
|
||||
}
|
||||
|
||||
.modal-fade-enter-active {
|
||||
transition: all 0.3s var(--ease-in-sine);
|
||||
}
|
||||
|
||||
.modal-fade-leave-active {
|
||||
transition: all 0.1s var(--ease-out-sine);
|
||||
}
|
||||
|
||||
.modal-fade-enter,
|
||||
.modal-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.network-notification-fade-enter-active {
|
||||
transition: all 0.1s var(--ease-in-sine);
|
||||
}
|
||||
|
||||
.network-notification-fade-leave-active {
|
||||
transition: all 0.1s var(--ease-out-sine);
|
||||
}
|
||||
|
||||
.network-notification-fade-enter,
|
||||
.network-notification-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-$space-small);
|
||||
}
|
||||
+6
-1
@@ -101,7 +101,7 @@ select {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>");
|
||||
background-size: 9px 6px;
|
||||
|
||||
@apply field-base h-10 bg-origin-content focus-visible:outline-none bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3;
|
||||
@apply field-base h-10 bg-origin-content bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3;
|
||||
|
||||
&[disabled] {
|
||||
@apply field-disabled;
|
||||
@@ -201,3 +201,8 @@ code {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Table
|
||||
table {
|
||||
@apply border-spacing-0 text-sm w-full;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
@import 'dashboard/assets/scss/variables';
|
||||
|
||||
.formulate-input {
|
||||
.formulate-input-errors {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.formulate-input-error {
|
||||
color: var(--r-400);
|
||||
display: block;
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: $font-weight-normal;
|
||||
margin-bottom: $space-one;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.integration-hooks {
|
||||
.formulate-input[data-type='checkbox'] {
|
||||
.formulate-input-wrapper {
|
||||
@apply flex;
|
||||
|
||||
.formulate-input-element {
|
||||
@apply pr-2;
|
||||
|
||||
input {
|
||||
@apply mb-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.formulate-input-element-decorator {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// loader class
|
||||
.spinner {
|
||||
@include color-spinner();
|
||||
@apply inline-block h-6 py-0 px-6 relative align-middle w-6;
|
||||
|
||||
&.message {
|
||||
@apply bg-white dark:bg-slate-800 rounded-full left-0 my-3 mx-auto p-4 top-0;
|
||||
|
||||
&::before {
|
||||
@apply -ml-3 -mt-3;
|
||||
}
|
||||
}
|
||||
|
||||
&.small {
|
||||
@apply h-4 w-4;
|
||||
|
||||
&::before {
|
||||
@apply h-4 -mt-2 w-4;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
// scss-lint:disable SpaceAfterPropertyColon
|
||||
@import 'shared/assets/fonts/inter';
|
||||
|
||||
// Inter,
|
||||
html,
|
||||
body {
|
||||
font-family:
|
||||
'Inter',
|
||||
-apple-system,
|
||||
system-ui,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
'Helvetica Neue',
|
||||
Tahoma,
|
||||
Arial,
|
||||
sans-serif !important;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-wrapper {
|
||||
@apply h-screen flex-grow-0 min-h-0 w-full;
|
||||
|
||||
.button--fixed-top {
|
||||
@apply fixed ltr:right-2 rtl:left-2 top-2 flex flex-row;
|
||||
}
|
||||
}
|
||||
|
||||
.banner + .app-wrapper {
|
||||
// Reduce the height of the dashboard to make room for the banner.
|
||||
// And causing the top right green-action button to be pushed down when scrolling.
|
||||
@apply h-[calc(100%-48px)];
|
||||
|
||||
.button--fixed-top {
|
||||
@apply top-14;
|
||||
}
|
||||
|
||||
.off-canvas-content {
|
||||
.button--fixed-top {
|
||||
@apply top-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
@import 'dashboard/assets/scss/variables';
|
||||
|
||||
$spinner-before-border-color: rgba(255, 255, 255, 0.7);
|
||||
|
||||
// input form
|
||||
@mixin ghost-input() {
|
||||
box-shadow: none;
|
||||
border-color: transparent;
|
||||
|
||||
&:active,
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin color-spinner() {
|
||||
@keyframes spinner {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
animation: spinner .9s linear infinite;
|
||||
border: 2px solid $spinner-before-border-color;
|
||||
border-radius: 50%;
|
||||
border-top-color: lighten($color-woot, 10%);
|
||||
box-sizing: border-box;
|
||||
content: '';
|
||||
height: $space-medium;
|
||||
left: 50%;
|
||||
margin-left: -$space-one;
|
||||
margin-top: -$space-one;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: $space-medium;
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// arrows
|
||||
// --------------------------------------------------------
|
||||
// $direction: top, left, right, bottom, top-left, top-right, bottom-left, bottom-right
|
||||
// $color: hex, rgb or rbga
|
||||
// $size: px or em
|
||||
// @example
|
||||
// .element{
|
||||
// @include arrow(top, #000, 50px);
|
||||
// }
|
||||
@mixin arrow($direction, $color, $size) {
|
||||
display: block;
|
||||
height: 0;
|
||||
width: 0;
|
||||
content: '';
|
||||
|
||||
@if $direction == 'top' {
|
||||
border-bottom: $size solid $color;
|
||||
border-left: $size solid transparent;
|
||||
border-right: $size solid transparent;
|
||||
}
|
||||
|
||||
@else if $direction == 'right' {
|
||||
border-bottom: $size solid transparent;
|
||||
border-left: $size solid $color;
|
||||
border-top: $size solid transparent;
|
||||
}
|
||||
|
||||
@else if $direction == 'bottom' {
|
||||
border-left: $size solid transparent;
|
||||
border-right: $size solid transparent;
|
||||
border-top: $size solid $color;
|
||||
}
|
||||
|
||||
@else if $direction == 'left' {
|
||||
border-bottom: $size solid transparent;
|
||||
border-right: $size solid $color;
|
||||
border-top: $size solid transparent;
|
||||
}
|
||||
|
||||
@else if $direction == 'top-left' {
|
||||
border-right: $size solid transparent;
|
||||
border-top: $size solid $color;
|
||||
}
|
||||
|
||||
@else if $direction == 'top-right' {
|
||||
border-left: $size solid transparent;
|
||||
border-top: $size solid $color;
|
||||
}
|
||||
|
||||
@else if $direction == 'bottom-left' {
|
||||
border-bottom: $size solid $color;
|
||||
border-right: $size solid transparent;
|
||||
}
|
||||
|
||||
@else if $direction == 'bottom-right' {
|
||||
border-bottom: $size solid $color;
|
||||
border-left: $size solid transparent;
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
.app-rtl--wrapper {
|
||||
direction: rtl;
|
||||
|
||||
// Woot Tabs
|
||||
.tabs-title {
|
||||
&:first-child {
|
||||
margin-left: var(--space-small);
|
||||
margin-right: unset;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-left: unset;
|
||||
margin-right: var(--space-small);
|
||||
}
|
||||
}
|
||||
|
||||
// woot tables
|
||||
table,
|
||||
thead,
|
||||
th {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
// Table footer
|
||||
.footer {
|
||||
.page-meta {
|
||||
direction: initial;
|
||||
}
|
||||
}
|
||||
|
||||
// Wizard box
|
||||
.wizard-box {
|
||||
direction: initial;
|
||||
}
|
||||
|
||||
// Conversation details
|
||||
.conversation-details-wrap {
|
||||
.conversation-panel {
|
||||
// Message text
|
||||
.text-content {
|
||||
p {
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: unset;
|
||||
padding-right: var(--space-two);
|
||||
}
|
||||
|
||||
li {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
// Message items and actions
|
||||
li {
|
||||
&.right {
|
||||
.sender--info {
|
||||
padding: var(--space-small) var(--space-smaller)
|
||||
var(--space-smaller) 0;
|
||||
}
|
||||
|
||||
.context-menu-wrap {
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Conversation footer
|
||||
.conversation-footer {
|
||||
.preview-item {
|
||||
direction: initial;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom attributes section in conversation sidebar
|
||||
.conversation-sidebar-wrap .checkbox-wrap {
|
||||
.checkbox {
|
||||
margin-left: var(--space-small);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Conversation list
|
||||
.conversations-list-wrap {
|
||||
border-right: 0;
|
||||
|
||||
.conversation {
|
||||
.conversation--meta {
|
||||
left: $space-normal;
|
||||
right: unset;
|
||||
|
||||
.unread {
|
||||
margin-left: unset;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.assignee-label {
|
||||
margin-left: 0;
|
||||
margin-right: var(--space-one);
|
||||
}
|
||||
|
||||
.show-more--button {
|
||||
margin: unset;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Basic filter dropdown
|
||||
.basic-filter {
|
||||
left: 0;
|
||||
right: unset;
|
||||
}
|
||||
|
||||
// Bulk actions
|
||||
.bulk-action__container {
|
||||
.triangle {
|
||||
left: var(--triangle-position);
|
||||
right: unset;
|
||||
}
|
||||
|
||||
.bulk-action__agents {
|
||||
left: var(--space-small);
|
||||
right: unset;
|
||||
}
|
||||
|
||||
.labels-container {
|
||||
left: var(--space-small);
|
||||
right: unset;
|
||||
|
||||
.label-checkbox {
|
||||
margin: 0 0 0 var(--space-one);
|
||||
}
|
||||
}
|
||||
|
||||
.actions-container {
|
||||
left: var(--space-small);
|
||||
right: unset;
|
||||
}
|
||||
|
||||
.bulk-action__teams {
|
||||
left: var(--space-small);
|
||||
right: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Contact notes
|
||||
.card.note-wrap {
|
||||
.time-stamp {
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle switch
|
||||
.toggle-button {
|
||||
&.small {
|
||||
span {
|
||||
&.active {
|
||||
transform: translate(var(--space-minus-small), var(--space-zero));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
--minus-space-one-point-five: -0.9375rem;
|
||||
|
||||
&.active {
|
||||
transform: translate(
|
||||
var(--minus-space-one-point-five),
|
||||
var(--space-zero)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Modal
|
||||
.modal-container {
|
||||
text-align: right;
|
||||
|
||||
.modal-footer {
|
||||
button {
|
||||
margin-left: 0;
|
||||
margin-right: var(--space-small);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Other changes
|
||||
.colorpicker--chrome {
|
||||
direction: initial;
|
||||
}
|
||||
|
||||
.mention--box {
|
||||
direction: initial;
|
||||
}
|
||||
|
||||
.contact--form .input-group {
|
||||
direction: initial;
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
// Font sizes
|
||||
$font-size-nano: 0.5rem;
|
||||
$font-size-micro: 0.675rem;
|
||||
$font-size-mini: 0.75rem;
|
||||
$font-size-small: 0.875rem;
|
||||
$font-size-default: 1rem;
|
||||
$font-size-medium: 1.125rem;
|
||||
$font-size-large: 1.375rem;
|
||||
$font-size-big: 1.5rem;
|
||||
$font-size-bigger: 1.75rem;
|
||||
$font-size-mega: 2.125rem;
|
||||
$font-size-giga: 2.5rem;
|
||||
|
||||
// spaces
|
||||
$zero: 0;
|
||||
$space-micro: 0.125rem;
|
||||
$space-smaller: 0.25rem;
|
||||
$space-small: 0.5rem;
|
||||
$space-one: 0.675rem;
|
||||
$space-slab: 0.75rem;
|
||||
$space-normal: 1rem;
|
||||
$space-two: 1.25rem;
|
||||
$space-medium: 1.5rem;
|
||||
$space-large: 2rem;
|
||||
$space-larger: 3rem;
|
||||
$space-jumbo: 4rem;
|
||||
$space-mega: 6.25rem;
|
||||
|
||||
// font-weight
|
||||
$font-weight-feather: 100;
|
||||
$font-weight-light: 300;
|
||||
$font-weight-normal: 400;
|
||||
$font-weight-medium: 500;
|
||||
$font-weight-bold: 600;
|
||||
$font-weight-black: 700;
|
||||
|
||||
//Navbar
|
||||
$nav-bar-width: 14.375rem;
|
||||
$header-height: 3.5rem;
|
||||
|
||||
$woot-logo-padding: $space-large $space-two;
|
||||
|
||||
// Colors
|
||||
$color-woot: #1f93ff;
|
||||
$color-gray: #6e6f73;
|
||||
$color-light-gray: #999a9b;
|
||||
|
||||
$color-border: var(--s-75);
|
||||
$color-border-light: var(--s-50);
|
||||
$color-border-dark: var(--s-100);
|
||||
|
||||
$color-background: var(--s-50);
|
||||
$color-background-light: var(--s-25);
|
||||
|
||||
$color-white: #fff;
|
||||
$color-body: #3c4858;
|
||||
$color-heading: #1f2d3d;
|
||||
$color-extra-light-blue: #f5f7f9;
|
||||
|
||||
$primary-color: $color-woot;
|
||||
$secondary-color: #5d7592;
|
||||
$success-color: #44ce4b;
|
||||
$warning-color: #ffc532;
|
||||
$alert-color: #ff382d;
|
||||
|
||||
$masked-bg: rgba(0, 0, 0, .4);
|
||||
|
||||
// Color-palettes
|
||||
|
||||
$color-primary-light: #c7e3ff;
|
||||
$color-primary-dark: darken($color-woot, 20%);
|
||||
|
||||
// Thumbnail
|
||||
$thumbnail-radius: 2.5rem;
|
||||
|
||||
// chat-header
|
||||
$conv-header-height: 2.5rem;
|
||||
|
||||
// Inbox List
|
||||
|
||||
$inbox-thumb-size: 3rem;
|
||||
|
||||
|
||||
// Snackbar default
|
||||
$woot-snackbar-bg: #323232;
|
||||
$woot-snackbar-button: #ffeb3b;
|
||||
|
||||
$swift-ease-out-duration: .4s !default;
|
||||
$swift-ease-out-function: cubic-bezier(0.37, 0, 0.63, 1) !default;
|
||||
$swift-ease-out: all $swift-ease-out-duration $swift-ease-out-function !default;
|
||||
|
||||
// Transitions
|
||||
$transition-ease-in: all 0.250s ease-in;
|
||||
|
||||
:root {
|
||||
--dashboard-app-tabs-height: 2.4375rem;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// scss-lint:disable SpaceAfterPropertyColon
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
@@ -8,54 +9,42 @@
|
||||
// Next Colors
|
||||
@import 'next-colors';
|
||||
|
||||
@import 'shared/assets/stylesheets/animations';
|
||||
@import 'shared/assets/stylesheets/colors';
|
||||
@import 'shared/assets/stylesheets/spacing';
|
||||
@import 'shared/assets/stylesheets/font-size';
|
||||
@import 'shared/assets/stylesheets/font-weights';
|
||||
@import 'shared/assets/stylesheets/shadows';
|
||||
@import 'shared/assets/stylesheets/border-radius';
|
||||
@import 'shared/assets/stylesheets/z-index';
|
||||
|
||||
@import 'variables';
|
||||
|
||||
@import 'mixins';
|
||||
@import 'helper-classes';
|
||||
@import 'formulate';
|
||||
@import 'date-picker';
|
||||
|
||||
@import 'layout';
|
||||
@import 'animations';
|
||||
@import 'rtl';
|
||||
|
||||
@import 'widgets/base';
|
||||
@import 'widgets/conversation-view';
|
||||
@import 'widgets/tabs';
|
||||
@import 'widgets/woot-tables';
|
||||
// Base styles for elements
|
||||
@import 'base';
|
||||
|
||||
// Plugins
|
||||
@import 'plugins/multiselect';
|
||||
@import 'plugins/dropdown';
|
||||
@import 'plugins/date-picker';
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family:
|
||||
'Inter',
|
||||
-apple-system,
|
||||
system-ui,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
'Helvetica Neue',
|
||||
Tahoma,
|
||||
Arial,
|
||||
sans-serif !important;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
@apply bg-slate-900 text-white py-1 px-2 z-40 text-xs rounded-md dark:bg-slate-200 dark:text-slate-900 max-w-96;
|
||||
@apply bg-n-solid-2 text-n-slate-12 py-1 px-2 z-40 text-xs rounded-md max-w-96;
|
||||
}
|
||||
|
||||
#app {
|
||||
@apply h-full w-full;
|
||||
}
|
||||
|
||||
.hide {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.n-blue-border {
|
||||
@apply border-n-blue-border;
|
||||
}
|
||||
|
||||
.n-blue-text {
|
||||
@apply text-n-blue-text;
|
||||
}
|
||||
|
||||
.custom-dashed-border {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23E2E3E7' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-position: center;
|
||||
@@ -67,351 +56,6 @@
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23343434' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
// scss-lint:disable PropertySortOrder
|
||||
@layer base {
|
||||
:root {
|
||||
--color-amber-25: 254 253 251;
|
||||
--color-amber-50: 255 249 237;
|
||||
--color-amber-75: 255 243 208;
|
||||
--color-amber-100: 255 236 183;
|
||||
--color-amber-200: 255 224 161;
|
||||
--color-amber-300: 245 208 140;
|
||||
--color-amber-400: 228 187 120;
|
||||
--color-amber-500: 214 163 92;
|
||||
--color-amber-600: 214 163 92;
|
||||
--color-amber-700: 255 186 26;
|
||||
--color-amber-800: 145 89 48;
|
||||
--color-amber-900: 79 52 34;
|
||||
|
||||
--color-ash-100: 235 235 239;
|
||||
--color-ash-200: 228 228 233;
|
||||
--color-ash-25: 252 252 253;
|
||||
--color-ash-300: 221 221 227;
|
||||
--color-ash-400: 211 212 219;
|
||||
--color-ash-50: 249 249 251;
|
||||
--color-ash-500: 185 187 198;
|
||||
--color-ash-600: 139 141 152;
|
||||
--color-ash-700: 126 128 138;
|
||||
--color-ash-75: 242 242 245;
|
||||
--color-ash-800: 96 100 108;
|
||||
--color-ash-900: 28 32 36;
|
||||
|
||||
--color-primary-25: 251 253 255;
|
||||
--color-primary-50: 245 249 255;
|
||||
--color-primary-75: 233 243 255;
|
||||
--color-primary-100: 218 236 255;
|
||||
--color-primary-200: 201 226 255;
|
||||
--color-primary-300: 181 213 255;
|
||||
--color-primary-400: 155 195 252;
|
||||
--color-primary-500: 117 171 247;
|
||||
--color-primary-600: 39 129 246;
|
||||
--color-primary-700: 16 115 233;
|
||||
--color-primary-800: 8 109 224;
|
||||
--color-primary-900: 11 50 101;
|
||||
|
||||
--color-ruby-100: 255 220 225;
|
||||
--color-ruby-200: 255 206 214;
|
||||
--color-ruby-25: 255 252 253;
|
||||
--color-ruby-300: 248 191 200;
|
||||
--color-ruby-400: 239 172 184;
|
||||
--color-ruby-50: 255 247 248;
|
||||
--color-ruby-500: 229 146 163;
|
||||
--color-ruby-600: 229 70 102;
|
||||
--color-ruby-700: 220 59 93;
|
||||
--color-ruby-75: 254 234 237;
|
||||
--color-ruby-800: 202 36 77;
|
||||
--color-ruby-900: 100 23 43;
|
||||
|
||||
--color-teal-100: 224 248 243;
|
||||
--color-teal-200: 204 243 234;
|
||||
--color-teal-25: 250 254 253;
|
||||
--color-teal-300: 184 234 224;
|
||||
--color-teal-400: 161 222 210;
|
||||
--color-teal-50: 243 251 249;
|
||||
--color-teal-500: 83 185 171;
|
||||
--color-teal-600: 18 165 148;
|
||||
--color-teal-700: 13 155 138;
|
||||
--color-teal-75: 236 249 255;
|
||||
--color-teal-800: 0 133 115;
|
||||
--color-teal-900: 13 61 56;
|
||||
|
||||
--color-green-25: 251 254 252;
|
||||
--color-green-50: 244 251 246;
|
||||
--color-green-75: 230 246 235;
|
||||
--color-green-100: 214 241 223;
|
||||
--color-green-200: 196 232 209;
|
||||
--color-green-300: 173 221 192;
|
||||
--color-green-400: 142 206 170;
|
||||
--color-green-500: 91 185 139;
|
||||
--color-green-600: 48 164 108;
|
||||
--color-green-700: 43 154 102;
|
||||
--color-green-800: 33 131 88;
|
||||
--color-green-900: 25 59 45;
|
||||
|
||||
--color-mint-25: 249 254 253;
|
||||
--color-mint-50: 242 251 249;
|
||||
--color-mint-75: 221 249 242;
|
||||
--color-mint-100: 200 244 233;
|
||||
--color-mint-200: 179 236 222;
|
||||
--color-mint-300: 156 224 208;
|
||||
--color-mint-400: 126 207 189;
|
||||
--color-mint-500: 76 187 165;
|
||||
--color-mint-600: 134 234 212;
|
||||
--color-mint-700: 125 224 203;
|
||||
--color-mint-800: 2 120 100;
|
||||
--color-mint-900: 22 67 60;
|
||||
|
||||
--color-sky-25: 249 254 255;
|
||||
--color-sky-50: 241 250 253;
|
||||
--color-sky-75: 225 246 253;
|
||||
--color-sky-100: 209 240 250;
|
||||
--color-sky-200: 190 231 245;
|
||||
--color-sky-300: 169 218 237;
|
||||
--color-sky-400: 141 202 227;
|
||||
--color-sky-500: 96 179 215;
|
||||
--color-sky-600: 124 226 254;
|
||||
--color-sky-700: 116 218 248;
|
||||
--color-sky-800: 0 116 158;
|
||||
--color-sky-900: 29 62 86;
|
||||
|
||||
--color-indigo-25: 253 253 254;
|
||||
--color-indigo-50: 247 249 255;
|
||||
--color-indigo-75: 237 242 254;
|
||||
--color-indigo-100: 225 233 255;
|
||||
--color-indigo-200: 210 222 255;
|
||||
--color-indigo-300: 193 208 255;
|
||||
--color-indigo-400: 171 189 249;
|
||||
--color-indigo-500: 141 164 239;
|
||||
--color-indigo-600: 62 99 221;
|
||||
--color-indigo-700: 51 88 212;
|
||||
--color-indigo-800: 58 91 199;
|
||||
--color-indigo-900: 31 45 92;
|
||||
|
||||
--color-iris-25: 253 253 255;
|
||||
--color-iris-50: 248 248 255;
|
||||
--color-iris-75: 240 241 254;
|
||||
--color-iris-100: 230 231 255;
|
||||
--color-iris-200: 218 220 255;
|
||||
--color-iris-300: 203 205 255;
|
||||
--color-iris-400: 184 186 248;
|
||||
--color-iris-500: 155 158 240;
|
||||
--color-iris-600: 91 91 214;
|
||||
--color-iris-700: 81 81 205;
|
||||
--color-iris-800: 87 83 198;
|
||||
--color-iris-900: 39 41 98;
|
||||
|
||||
--color-violet-25: 253 252 254;
|
||||
--color-violet-50: 250 248 255;
|
||||
--color-violet-75: 244 240 254;
|
||||
--color-violet-100: 235 228 255;
|
||||
--color-violet-200: 225 217 255;
|
||||
--color-violet-300: 212 202 254;
|
||||
--color-violet-400: 194 181 245;
|
||||
--color-violet-500: 170 153 236;
|
||||
--color-violet-600: 110 86 207;
|
||||
--color-violet-700: 101 77 196;
|
||||
--color-violet-800: 101 80 185;
|
||||
--color-violet-900: 47 38 95;
|
||||
|
||||
--color-pink-25: 255 252 254;
|
||||
--color-pink-50: 254 247 251;
|
||||
--color-pink-75: 254 233 245;
|
||||
--color-pink-100: 251 220 239;
|
||||
--color-pink-200: 246 206 231;
|
||||
--color-pink-300: 239 191 221;
|
||||
--color-pink-400: 231 172 208;
|
||||
--color-pink-500: 221 147 194;
|
||||
--color-pink-600: 214 64 159;
|
||||
--color-pink-700: 207 56 151;
|
||||
--color-pink-800: 194 41 138;
|
||||
--color-pink-900: 101 18 73;
|
||||
|
||||
--color-orange-25: 254 252 251;
|
||||
--color-orange-50: 255 247 237;
|
||||
--color-orange-75: 255 239 214;
|
||||
--color-orange-100: 255 223 181;
|
||||
--color-orange-200: 255 209 154;
|
||||
--color-orange-300: 255 193 130;
|
||||
--color-orange-400: 245 174 115;
|
||||
--color-orange-500: 236 148 85;
|
||||
--color-orange-600: 247 107 21;
|
||||
--color-orange-700: 239 95 0;
|
||||
--color-orange-800: 204 78 0;
|
||||
--color-orange-900: 88 45 29;
|
||||
}
|
||||
|
||||
// scss-lint:disable QualifyingElement
|
||||
body.dark {
|
||||
--color-amber-25: 31 19 0;
|
||||
--color-amber-50: 37 24 4;
|
||||
--color-amber-75: 48 32 11;
|
||||
--color-amber-100: 57 39 15;
|
||||
--color-amber-200: 67 46 18;
|
||||
--color-amber-300: 83 57 22;
|
||||
--color-amber-400: 111 77 29;
|
||||
--color-amber-500: 169 118 42;
|
||||
--color-amber-600: 169 118 42;
|
||||
--color-amber-700: 255 203 71;
|
||||
--color-amber-800: 255 204 77;
|
||||
--color-amber-900: 255 231 179;
|
||||
|
||||
--color-ash-100: 46 48 53;
|
||||
--color-ash-200: 53 55 60;
|
||||
--color-ash-25: 24 24 26;
|
||||
--color-ash-300: 60 63 68;
|
||||
--color-ash-400: 70 75 80;
|
||||
--color-ash-50: 27 27 31;
|
||||
--color-ash-500: 90 97 101;
|
||||
--color-ash-600: 105 110 119;
|
||||
--color-ash-700: 120 127 133;
|
||||
--color-ash-75: 39 40 45;
|
||||
--color-ash-800: 173 177 184;
|
||||
--color-ash-900: 237 238 240;
|
||||
|
||||
--color-primary-25: 10 17 28;
|
||||
--color-primary-50: 15 24 38;
|
||||
--color-primary-75: 15 39 72;
|
||||
--color-primary-100: 10 49 99;
|
||||
--color-primary-200: 18 61 117;
|
||||
--color-primary-300: 29 74 134;
|
||||
--color-primary-400: 40 89 156;
|
||||
--color-primary-500: 48 106 186;
|
||||
--color-primary-600: 39 129 246;
|
||||
--color-primary-700: 21 116 231;
|
||||
--color-primary-800: 126 182 255;
|
||||
--color-primary-900: 205 227 255;
|
||||
|
||||
--color-ruby-100: 78 19 37;
|
||||
--color-ruby-200: 94 26 46;
|
||||
--color-ruby-25: 25 17 19;
|
||||
--color-ruby-300: 111 37 57;
|
||||
--color-ruby-400: 136 52 71;
|
||||
--color-ruby-50: 30 21 23;
|
||||
--color-ruby-500: 179 68 90;
|
||||
--color-ruby-600: 229 70 102;
|
||||
--color-ruby-700: 236 90 114;
|
||||
--color-ruby-75: 58 20 30;
|
||||
--color-ruby-800: 255 148 157;
|
||||
--color-ruby-900: 254 210 225;
|
||||
|
||||
--color-teal-100: 2 59 55;
|
||||
--color-teal-200: 8 72 67;
|
||||
--color-teal-25: 13 21 20;
|
||||
--color-teal-300: 28 105 97;
|
||||
--color-teal-400: 28 105 97;
|
||||
--color-teal-50: 17 28 27;
|
||||
--color-teal-500: 32 126 115;
|
||||
--color-teal-600: 41 163 131;
|
||||
--color-teal-700: 14 179 158;
|
||||
--color-teal-75: 13 45 42;
|
||||
--color-teal-800: 11 216 182;
|
||||
--color-teal-900: 173 240 221;
|
||||
|
||||
--color-green-25: 14 21 18;
|
||||
--color-green-50: 18 27 23;
|
||||
--color-green-75: 19 45 33;
|
||||
--color-green-100: 17 59 41;
|
||||
--color-green-200: 23 73 51;
|
||||
--color-green-300: 32 87 62;
|
||||
--color-green-400: 40 104 74;
|
||||
--color-green-500: 47 124 87;
|
||||
--color-green-600: 48 164 108;
|
||||
--color-green-700: 51 176 116;
|
||||
--color-green-800: 61 214 140;
|
||||
--color-green-900: 177 241 203;
|
||||
|
||||
--color-mint-25: 14 21 21;
|
||||
--color-mint-50: 15 27 27;
|
||||
--color-mint-75: 9 44 43;
|
||||
--color-mint-100: 0 58 56;
|
||||
--color-mint-200: 0 71 68;
|
||||
--color-mint-300: 16 86 80;
|
||||
--color-mint-400: 30 104 95;
|
||||
--color-mint-500: 39 127 112;
|
||||
--color-mint-600: 134 234 212;
|
||||
--color-mint-700: 168 245 229;
|
||||
--color-mint-800: 88 213 186;
|
||||
--color-mint-900: 196 245 225;
|
||||
|
||||
--color-sky-25: 14 21 21;
|
||||
--color-sky-50: 15 27 27;
|
||||
--color-sky-75: 9 44 43;
|
||||
--color-sky-100: 0 58 56;
|
||||
--color-sky-200: 0 71 68;
|
||||
--color-sky-300: 16 86 80;
|
||||
--color-sky-400: 30 104 95;
|
||||
--color-sky-500: 39 127 112;
|
||||
--color-sky-600: 134 234 212;
|
||||
--color-sky-700: 168 245 229;
|
||||
--color-sky-800: 88 213 186;
|
||||
--color-sky-900: 196 245 225;
|
||||
|
||||
--color-indigo-25: 17 19 31;
|
||||
--color-indigo-50: 20 23 38;
|
||||
--color-indigo-75: 24 36 73;
|
||||
--color-indigo-100: 29 46 98;
|
||||
--color-indigo-200: 37 57 116;
|
||||
--color-indigo-300: 48 67 132;
|
||||
--color-indigo-400: 58 79 151;
|
||||
--color-indigo-500: 67 93 177;
|
||||
--color-indigo-600: 62 99 221;
|
||||
--color-indigo-700: 84 114 228;
|
||||
--color-indigo-800: 158 177 255;
|
||||
--color-indigo-900: 214 225 255;
|
||||
|
||||
--color-iris-25: 19 19 30;
|
||||
--color-iris-50: 23 22 37;
|
||||
--color-iris-75: 32 34 72;
|
||||
--color-iris-100: 38 42 101;
|
||||
--color-iris-200: 48 51 116;
|
||||
--color-iris-300: 61 62 130;
|
||||
--color-iris-400: 74 74 149;
|
||||
--color-iris-500: 89 88 177;
|
||||
--color-iris-600: 91 91 214;
|
||||
--color-iris-700: 110 106 222;
|
||||
--color-iris-800: 177 169 255;
|
||||
--color-iris-900: 224 223 254;
|
||||
|
||||
--color-violet-25: 20 18 31;
|
||||
--color-violet-50: 27 21 37;
|
||||
--color-violet-75: 41 31 67;
|
||||
--color-violet-100: 51 37 91;
|
||||
--color-violet-200: 60 46 105;
|
||||
--color-violet-300: 71 56 118;
|
||||
--color-violet-400: 86 70 139;
|
||||
--color-violet-500: 105 88 173;
|
||||
--color-violet-600: 110 86 207;
|
||||
--color-violet-700: 125 102 217;
|
||||
--color-violet-800: 186 167 255;
|
||||
--color-violet-900: 226 221 254;
|
||||
|
||||
--color-pink-25: 25 17 23;
|
||||
--color-pink-50: 33 18 29;
|
||||
--color-pink-75: 55 23 47;
|
||||
--color-pink-100: 75 20 61;
|
||||
--color-pink-200: 89 28 71;
|
||||
--color-pink-300: 105 41 85;
|
||||
--color-pink-400: 131 56 105;
|
||||
--color-pink-500: 168 72 133;
|
||||
--color-pink-600: 214 64 159;
|
||||
--color-pink-700: 222 81 168;
|
||||
--color-pink-800: 255 141 204;
|
||||
--color-pink-900: 253 209 234;
|
||||
--color-orange-25: 23 18 14;
|
||||
--color-orange-50: 30 22 15;
|
||||
--color-orange-75: 51 30 11;
|
||||
--color-orange-100: 70 33 0;
|
||||
--color-orange-200: 86 40 0;
|
||||
--color-orange-300: 102 53 12;
|
||||
--color-orange-400: 126 69 29;
|
||||
--color-orange-500: 163 88 41;
|
||||
--color-orange-600: 247 107 21;
|
||||
--color-orange-700: 255 128 31;
|
||||
--color-orange-800: 255 160 87;
|
||||
--color-orange-900: 255 224 194;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
|
||||
+8
-8
@@ -30,11 +30,11 @@
|
||||
|
||||
.mx-input:disabled,
|
||||
.mx-input[readonly] {
|
||||
@apply bg-white dark:bg-slate-900 cursor-pointer;
|
||||
@apply bg-n-background cursor-pointer;
|
||||
}
|
||||
|
||||
.mx-icon-calendar {
|
||||
@apply dark:text-slate-500;
|
||||
@apply text-n-slate-10;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,17 +43,17 @@
|
||||
|
||||
.cell {
|
||||
&.disabled {
|
||||
@apply bg-slate-25 dark:bg-slate-900 text-slate-200 dark:text-slate-300;
|
||||
@apply bg-n-slate-2 dark:bg-n-background text-n-slate-10;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.hover-in-range,
|
||||
&.in-range {
|
||||
@apply bg-slate-75 dark:bg-slate-700 text-slate-900 dark:text-slate-100;
|
||||
@apply bg-n-slate-3 dark:bg-n-solid-3 text-n-slate-12;
|
||||
}
|
||||
}
|
||||
|
||||
.mx-calendar+.mx-calendar {
|
||||
.mx-calendar + .mx-calendar {
|
||||
@apply border-l border-n-weak;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
}
|
||||
|
||||
.mx-time {
|
||||
@apply border-0 bg-white dark:bg-slate-800;
|
||||
@apply border-0 bg-n-background dark:bg-n-solid-2;
|
||||
|
||||
.mx-time-header {
|
||||
@apply border-0;
|
||||
@@ -70,11 +70,11 @@
|
||||
|
||||
.mx-time-item {
|
||||
&.disabled {
|
||||
@apply bg-slate-25 dark:bg-slate-900;
|
||||
@apply bg-n-slate-2 dark:bg-n-background;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@apply bg-slate-75 dark:bg-slate-700;
|
||||
@apply bg-n-slate-3 dark:bg-n-solid-3;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
.dropdown-pane {
|
||||
@apply border rounded-lg hidden relative invisible shadow-lg border-n-strong dark:border-n-strong box-content p-2 w-fit z-[9999];
|
||||
|
||||
&.dropdown-pane--open {
|
||||
@apply bg-n-alpha-3 backdrop-blur-[100px] absolute block visible;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,10 @@
|
||||
@apply mb-4;
|
||||
}
|
||||
|
||||
&.invalid .multiselect__tags {
|
||||
@apply border-0 outline outline-1 outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 disabled:outline-n-ruby-8 dark:disabled:outline-n-ruby-8;
|
||||
}
|
||||
|
||||
&.multiselect--disabled {
|
||||
@apply opacity-50 rounded-lg cursor-not-allowed pointer-events-auto;
|
||||
|
||||
@@ -47,7 +51,7 @@
|
||||
@apply max-w-full;
|
||||
|
||||
.multiselect__option {
|
||||
@apply text-sm font-normal;
|
||||
@apply text-sm font-normal flex justify-between items-center;
|
||||
|
||||
span {
|
||||
@apply inline-block overflow-hidden text-ellipsis whitespace-nowrap w-fit;
|
||||
@@ -58,7 +62,7 @@
|
||||
}
|
||||
|
||||
&::after {
|
||||
@apply bottom-0 flex items-center justify-center text-center;
|
||||
@apply bottom-0 flex items-center justify-center text-center relative px-1 leading-tight;
|
||||
}
|
||||
|
||||
&.multiselect__option--highlight {
|
||||
@@ -74,7 +78,7 @@
|
||||
}
|
||||
|
||||
&.multiselect__option--highlight::after {
|
||||
@apply bg-transparent;
|
||||
@apply bg-transparent text-n-slate-12;
|
||||
}
|
||||
|
||||
&.multiselect__option--selected {
|
||||
@@ -124,8 +128,7 @@
|
||||
}
|
||||
|
||||
.multiselect__input {
|
||||
@include ghost-input;
|
||||
@apply text-sm h-[2.875rem] mb-0 p-0;
|
||||
@apply text-sm h-[2.875rem] mb-0 p-0 shadow-none border-transparent hover:border-transparent hover:shadow-none focus:border-transparent focus:shadow-none active:border-transparent active:shadow-none;
|
||||
}
|
||||
|
||||
.multiselect__single {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
// to be removed
|
||||
@@ -1 +0,0 @@
|
||||
// to be removed
|
||||
@@ -1,261 +0,0 @@
|
||||
// scss-lint:disable MergeableSelector
|
||||
|
||||
@tailwind utilities;
|
||||
@layer utilities {
|
||||
.custom-gradient {
|
||||
background-image: linear-gradient(
|
||||
-180deg,
|
||||
transparent 3%,
|
||||
rgb(76 81 85) 130%
|
||||
);
|
||||
}
|
||||
|
||||
.bubble-with-types {
|
||||
@apply py-2 text-sm font-normal bg-woot-500 dark:bg-woot-500 relative px-4 m-0 text-white dark:text-white;
|
||||
|
||||
.message-text__wrap {
|
||||
@apply relative;
|
||||
|
||||
.link {
|
||||
@apply text-white dark:text-white underline;
|
||||
}
|
||||
}
|
||||
|
||||
.image,
|
||||
.video {
|
||||
@apply cursor-pointer relative;
|
||||
|
||||
.modal-container {
|
||||
@apply text-center;
|
||||
}
|
||||
|
||||
.modal-image {
|
||||
@apply max-h-[76vh] max-w-[76vw];
|
||||
}
|
||||
|
||||
.modal-video {
|
||||
@apply max-h-[76vh] max-w-[76vw];
|
||||
}
|
||||
|
||||
&::before {
|
||||
@apply custom-gradient bottom-0 h-[20%] content-[''] left-0 absolute w-full opacity-80;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.conversation-panel {
|
||||
@apply flex-shrink flex-grow basis-px flex flex-col overflow-y-auto relative h-full m-0 pb-4;
|
||||
}
|
||||
|
||||
.conversation-panel > li {
|
||||
@apply flex flex-shrink-0 flex-grow-0 flex-auto max-w-full mt-0 mr-0 mb-1 ml-0 relative first:mt-auto last:mb-0;
|
||||
|
||||
&.unread--toast {
|
||||
+ .right {
|
||||
@apply mb-1;
|
||||
}
|
||||
|
||||
+ .left {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
span {
|
||||
@apply shadow-lg rounded-full bg-woot-500 dark:bg-woot-500 text-white dark:text-white text-xs font-medium my-2.5 mx-auto px-2.5 py-1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.bubble {
|
||||
@apply bubble-with-types text-left break-words;
|
||||
|
||||
.aplayer {
|
||||
@apply shadow-none;
|
||||
font-family: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
&.left {
|
||||
.bubble {
|
||||
@apply rounded-r-lg rounded-l mr-auto break-words;
|
||||
|
||||
&:not(.is-unsupported) {
|
||||
@apply border border-slate-50 dark:border-slate-700 bg-white dark:bg-slate-700 text-black-900 dark:text-slate-50;
|
||||
}
|
||||
|
||||
&.is-image {
|
||||
@apply rounded-lg;
|
||||
}
|
||||
|
||||
.link {
|
||||
@apply text-woot-600 dark:text-woot-600;
|
||||
}
|
||||
|
||||
.file {
|
||||
.attachment-name {
|
||||
@apply text-slate-700 dark:text-woot-300;
|
||||
}
|
||||
|
||||
.icon-wrap {
|
||||
@apply text-woot-600 dark:text-woot-600;
|
||||
}
|
||||
|
||||
.download {
|
||||
@apply text-woot-600 dark:text-woot-600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+ .right {
|
||||
@apply mt-2.5;
|
||||
|
||||
.bubble {
|
||||
@apply rounded-tr-lg;
|
||||
}
|
||||
}
|
||||
|
||||
+ .unread--toast {
|
||||
+ .right {
|
||||
@apply mt-2.5;
|
||||
|
||||
.bubble {
|
||||
@apply rounded-tr-lg;
|
||||
}
|
||||
}
|
||||
|
||||
+ .left {
|
||||
@apply mt-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.right {
|
||||
@apply justify-end;
|
||||
|
||||
.wrap {
|
||||
@apply flex items-end mr-4 text-right;
|
||||
|
||||
.sender--info {
|
||||
@apply pt-2 pb-1 pr-0 pl-2;
|
||||
}
|
||||
}
|
||||
|
||||
.bubble {
|
||||
@apply ml-auto break-words rounded-l-lg rounded-r;
|
||||
|
||||
&.is-private {
|
||||
@apply text-black-900 dark:text-white relative border border-solid bg-yellow-100 dark:bg-yellow-700 border-yellow-200 dark:border-yellow-600/25;
|
||||
|
||||
blockquote {
|
||||
@apply border-slate-400 dark:border-slate-400 text-slate-800 dark:text-slate-300;
|
||||
|
||||
p {
|
||||
@apply text-slate-600 dark:text-slate-300;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-image {
|
||||
@apply rounded-lg;
|
||||
|
||||
.message__mail-head {
|
||||
@apply px-4 py-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+ .left {
|
||||
@apply mt-2.5;
|
||||
|
||||
.bubble {
|
||||
@apply rounded-tl-lg;
|
||||
}
|
||||
}
|
||||
|
||||
+ .unread--toast {
|
||||
+ .left {
|
||||
@apply rounded-lg;
|
||||
|
||||
.bubble {
|
||||
@apply rounded-tl-lg;
|
||||
}
|
||||
}
|
||||
|
||||
+ .right {
|
||||
@apply mt-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.center {
|
||||
@apply items-center justify-center;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: Min(31rem, 84%);
|
||||
@apply my-0 mx-4;
|
||||
|
||||
.sender--name {
|
||||
@apply text-xs mb-1;
|
||||
}
|
||||
}
|
||||
|
||||
.sender--thumbnail {
|
||||
@apply h-3 mr-3 mt-0.5 w-3 rounded-full;
|
||||
}
|
||||
|
||||
.activity-wrap {
|
||||
@apply flex justify-center text-sm my-1 mx-0 py-1 pr-0.5 pl-2.5 bg-slate-50 dark:bg-slate-600 text-slate-800 dark:text-slate-100 rounded-md border border-slate-100 dark:border-slate-600 border-solid;
|
||||
|
||||
.is-text {
|
||||
@apply inline-flex items-center text-start 2xl:flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.activity-wrap .message-text__wrap {
|
||||
.text-content p {
|
||||
@apply mb-0;
|
||||
}
|
||||
}
|
||||
|
||||
.conversation-footer {
|
||||
@apply flex relative flex-col;
|
||||
}
|
||||
|
||||
.left .bubble .text-content {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply text-slate-800 dark:text-slate-100;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-woot-500 dark:text-woot-500 underline;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
@apply mb-0;
|
||||
}
|
||||
}
|
||||
|
||||
.right .bubble .text-content {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply text-white dark:text-white;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-white dark:text-white underline;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
@apply mb-0;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
.tabs--container {
|
||||
@apply flex;
|
||||
}
|
||||
|
||||
.tabs--container--with-border {
|
||||
@apply border-b border-n-weak;
|
||||
}
|
||||
|
||||
.tabs--container--compact.tab--chat-type {
|
||||
.tabs-title {
|
||||
a {
|
||||
@apply py-2 text-sm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
@apply border-r-0 border-l-0 border-t-0 flex min-w-[6.25rem] py-0 px-4 list-none mb-0;
|
||||
}
|
||||
|
||||
.tabs--with-scroll {
|
||||
@apply overflow-hidden py-0 px-1;
|
||||
max-width: calc(100% - 64px);
|
||||
}
|
||||
|
||||
.tabs--scroll-button {
|
||||
@apply items-center rounded-none cursor-pointer flex h-auto justify-center min-w-[2rem];
|
||||
}
|
||||
|
||||
// Tab chat type
|
||||
.tab--chat-type {
|
||||
@apply flex;
|
||||
|
||||
.tabs-title {
|
||||
a {
|
||||
@apply text-base font-medium py-3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tabs-title {
|
||||
@apply flex-shrink-0 my-0 mx-2;
|
||||
|
||||
.badge {
|
||||
@apply bg-n-alpha-black2 dark:bg-n-solid-3 rounded-md text-n-slate-11 h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 py-0;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
@apply ml-0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
@apply mr-0;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
a {
|
||||
@apply text-n-slate-12;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
@apply flex items-center flex-row border-b py-2.5 select-none cursor-pointer border-transparent text-n-slate-11 text-sm top-[1px] relative;
|
||||
transition: border-color 0.15s $swift-ease-out-function;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
a {
|
||||
@apply border-b border-n-brand text-n-blue-text;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply bg-n-brand/10 dark:bg-n-brand/20 text-n-blue-text;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
table {
|
||||
@apply border-spacing-0 text-sm w-full;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.woot-table {
|
||||
thead {
|
||||
th {
|
||||
@apply font-semibold tracking-[1px] text-left px-2.5 uppercase text-slate-900 dark:text-slate-200;
|
||||
}
|
||||
}
|
||||
|
||||
tbody {
|
||||
tr {
|
||||
@apply border-b border-slate-50 dark:border-slate-800/30;
|
||||
}
|
||||
|
||||
td {
|
||||
@apply p-2.5 text-slate-700 dark:text-slate-100;
|
||||
}
|
||||
}
|
||||
|
||||
tr {
|
||||
.show-if-hover {
|
||||
transition: opacity 0.2s $swift-ease-out-function;
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.show-if-hover {
|
||||
@apply opacity-100;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.agent-name {
|
||||
@apply block font-medium capitalize;
|
||||
}
|
||||
|
||||
.woot-thumbnail {
|
||||
@apply rounded-full h-[3.125rem] w-[3.125rem];
|
||||
}
|
||||
|
||||
.button-wrapper {
|
||||
@apply flex justify-start flex-row min-w-[12.5rem] gap-1;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ve-table {
|
||||
.ve-table-container.ve-table-border-around {
|
||||
@apply border-slate-200 dark:border-slate-700;
|
||||
}
|
||||
|
||||
.ve-table-content {
|
||||
.ve-table-header .ve-table-header-tr .ve-table-header-th {
|
||||
@apply bg-slate-50 dark:bg-slate-800 text-slate-800 dark:text-slate-100 border-slate-100 dark:border-slate-700/50;
|
||||
}
|
||||
|
||||
.ve-table-body .ve-table-body-tr .ve-table-body-td {
|
||||
@apply bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border-slate-75 dark:border-slate-800;
|
||||
}
|
||||
|
||||
.ve-table-body.ve-table-row-hover .ve-table-body-tr:hover td {
|
||||
@apply bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-100;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-pagination {
|
||||
.ve-pagination-total {
|
||||
@apply text-slate-600 dark:text-slate-200;
|
||||
}
|
||||
|
||||
.ve-pagination-goto {
|
||||
@apply text-slate-600 dark:text-slate-200;
|
||||
|
||||
.ve-pagination-goto-input {
|
||||
@apply bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-200;
|
||||
}
|
||||
}
|
||||
|
||||
.ve-pagination-li {
|
||||
@apply bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-200 border-slate-75 dark:border-slate-700;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, required: true },
|
||||
isOpen: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const isExpanded = ref(props.isOpen);
|
||||
|
||||
const toggleAccordion = () => {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.isOpen,
|
||||
newValue => {
|
||||
isExpanded.value = newValue;
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border rounded-lg border-n-slate-4">
|
||||
<button
|
||||
class="flex items-center justify-between w-full p-4 text-left"
|
||||
@click="toggleAccordion"
|
||||
>
|
||||
<span class="text-sm font-medium text-n-slate-12">{{ title }}</span>
|
||||
<span
|
||||
class="w-5 h-5 transition-transform duration-200 i-lucide-chevron-down"
|
||||
:class="{ 'rotate-180': isExpanded }"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="isExpanded" class="p-4 pt-0">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import AgentCapacityPolicyCard from './AgentCapacityPolicyCard.vue';
|
||||
|
||||
const mockUsers = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'John Smith',
|
||||
email: 'john.smith@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Sarah Johnson',
|
||||
email: 'sarah.johnson@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=2',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Mike Chen',
|
||||
email: 'mike.chen@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=3',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Emily Davis',
|
||||
email: 'emily.davis@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=4',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Alex Rodriguez',
|
||||
email: 'alex.rodriguez@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=5',
|
||||
},
|
||||
];
|
||||
|
||||
const withCount = policy => ({
|
||||
...policy,
|
||||
assignedAgentCount: policy.users.length,
|
||||
});
|
||||
|
||||
const policyA = withCount({
|
||||
id: 1,
|
||||
name: 'High Volume Support',
|
||||
description:
|
||||
'Capacity-based policy for handling high conversation volumes with experienced agents',
|
||||
users: [mockUsers[0], mockUsers[1], mockUsers[2]],
|
||||
isFetchingUsers: false,
|
||||
});
|
||||
|
||||
const policyB = withCount({
|
||||
id: 2,
|
||||
name: 'Specialized Team',
|
||||
description: 'Custom capacity limits for specialized support team members',
|
||||
users: [mockUsers[3], mockUsers[4]],
|
||||
isFetchingUsers: false,
|
||||
});
|
||||
|
||||
const emptyPolicy = withCount({
|
||||
id: 3,
|
||||
name: 'New Policy',
|
||||
description: 'Recently created policy with no assigned agents yet',
|
||||
users: [],
|
||||
isFetchingUsers: false,
|
||||
});
|
||||
|
||||
const loadingPolicy = withCount({
|
||||
id: 4,
|
||||
name: 'Loading Policy',
|
||||
description: 'Policy currently loading agent information',
|
||||
users: [],
|
||||
isFetchingUsers: true,
|
||||
});
|
||||
|
||||
const onEdit = id => console.log('Edit policy:', id);
|
||||
const onDelete = id => console.log('Delete policy:', id);
|
||||
const onFetchUsers = id => console.log('Fetch users for policy:', id);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/AgentCapacityPolicyCard"
|
||||
:layout="{ type: 'grid', width: '1200px' }"
|
||||
>
|
||||
<Variant title="Multiple Cards (Various States)">
|
||||
<div class="p-4 bg-n-background">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<AgentCapacityPolicyCard
|
||||
v-bind="policyA"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@fetch-users="onFetchUsers"
|
||||
/>
|
||||
<AgentCapacityPolicyCard
|
||||
v-bind="policyB"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@fetch-users="onFetchUsers"
|
||||
/>
|
||||
<AgentCapacityPolicyCard
|
||||
v-bind="emptyPolicy"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@fetch-users="onFetchUsers"
|
||||
/>
|
||||
<AgentCapacityPolicyCard
|
||||
v-bind="loadingPolicy"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@fetch-users="onFetchUsers"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import CardPopover from '../components/CardPopover.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: Number, required: true },
|
||||
name: { type: String, default: '' },
|
||||
description: { type: String, default: '' },
|
||||
assignedAgentCount: { type: Number, default: 0 },
|
||||
users: { type: Array, default: () => [] },
|
||||
isFetchingUsers: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit', 'delete', 'fetchUsers']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const users = computed(() => {
|
||||
return props.users.map(user => {
|
||||
return {
|
||||
name: user.name,
|
||||
key: user.id,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const handleEdit = () => {
|
||||
emit('edit', props.id);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
emit('delete', props.id);
|
||||
};
|
||||
|
||||
const handleFetchUsers = () => {
|
||||
if (props.users?.length > 0) return;
|
||||
emit('fetchUsers', props.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout class="[&>div]:px-5">
|
||||
<div class="flex flex-col gap-2 relative justify-between w-full">
|
||||
<div class="flex items-center gap-3 justify-between w-full">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
|
||||
{{ name }}
|
||||
</h3>
|
||||
<CardPopover
|
||||
:title="
|
||||
t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.INDEX.CARD.POPOVER')
|
||||
"
|
||||
icon="i-lucide-users-round"
|
||||
:count="assignedAgentCount"
|
||||
:items="users"
|
||||
:is-fetching="isFetchingUsers"
|
||||
@fetch="handleFetchUsers"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:label="
|
||||
t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.INDEX.CARD.EDIT')
|
||||
"
|
||||
sm
|
||||
slate
|
||||
link
|
||||
class="px-2"
|
||||
@click="handleEdit"
|
||||
/>
|
||||
<div class="w-px h-2.5 bg-n-slate-5" />
|
||||
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</template>
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import AssignmentCard from './AssignmentCard.vue';
|
||||
|
||||
const agentAssignments = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Assignment policy',
|
||||
description: 'Manage how conversations get assigned in inboxes.',
|
||||
features: [
|
||||
{
|
||||
icon: 'i-lucide-circle-fading-arrow-up',
|
||||
label: 'Assign by conversations evenly or by available capacity',
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-scale',
|
||||
label: 'Add fair distribution rules to avoid overloading any agent',
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-inbox',
|
||||
label: 'Add inboxes to a policy - one policy per inbox',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Agent capacity policy',
|
||||
description: 'Manage workload for agents.',
|
||||
features: [
|
||||
{
|
||||
icon: 'i-lucide-glass-water',
|
||||
label: 'Define maximum conversations per inbox',
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-circle-minus',
|
||||
label: 'Create exceptions based on labels and time',
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-users-round',
|
||||
label: 'Add agents to a policy - one policy per agent',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/AssignmentCard"
|
||||
:layout="{ type: 'grid', width: '1000px' }"
|
||||
>
|
||||
<Variant title="Assignment Card">
|
||||
<div class="px-4 py-4 bg-n-background flex gap-6 justify-between">
|
||||
<AssignmentCard
|
||||
v-for="(item, index) in agentAssignments"
|
||||
:key="index"
|
||||
:title="item.title"
|
||||
:description="item.description"
|
||||
:features="item.features"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
description: { type: String, default: '' },
|
||||
features: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const handleClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout class="[&>div]:px-5 cursor-pointer" @click="handleClick">
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<div class="flex justify-between w-full items-center">
|
||||
<h3 class="text-n-slate-12 text-base font-medium">{{ title }}</h3>
|
||||
<Button
|
||||
xs
|
||||
slate
|
||||
ghost
|
||||
icon="i-lucide-chevron-right"
|
||||
@click.stop="handleClick"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-n-slate-11 text-sm mb-0">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<ul class="flex flex-col items-start gap-3 mt-3">
|
||||
<li
|
||||
v-for="feature in features"
|
||||
:key="feature.id"
|
||||
class="flex items-center gap-3 text-sm"
|
||||
>
|
||||
<Icon
|
||||
:icon="feature.icon"
|
||||
class="text-n-slate-11 size-4 flex-shrink-0"
|
||||
/>
|
||||
{{ feature.label }}
|
||||
</li>
|
||||
</ul>
|
||||
</CardLayout>
|
||||
</template>
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
<script setup>
|
||||
import AssignmentPolicyCard from './AssignmentPolicyCard.vue';
|
||||
|
||||
const mockInboxes = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Website Support',
|
||||
channel_type: 'Channel::WebWidget',
|
||||
inbox_type: 'Website',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Email Support',
|
||||
channel_type: 'Channel::Email',
|
||||
inbox_type: 'Email',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'WhatsApp Business',
|
||||
channel_type: 'Channel::Whatsapp',
|
||||
inbox_type: 'WhatsApp',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Facebook Messenger',
|
||||
channel_type: 'Channel::FacebookPage',
|
||||
inbox_type: 'Messenger',
|
||||
},
|
||||
];
|
||||
|
||||
const withCount = policy => ({
|
||||
...policy,
|
||||
assignedInboxCount: policy.inboxes.length,
|
||||
});
|
||||
|
||||
const policyA = withCount({
|
||||
id: 1,
|
||||
name: 'Website & Email',
|
||||
description: 'Distributes conversations evenly among available agents',
|
||||
assignmentOrder: 'round_robin',
|
||||
conversationPriority: 'high',
|
||||
enabled: true,
|
||||
inboxes: [mockInboxes[0], mockInboxes[1]],
|
||||
isFetchingInboxes: false,
|
||||
});
|
||||
|
||||
const policyB = withCount({
|
||||
id: 2,
|
||||
name: 'WhatsApp & Messenger',
|
||||
description: 'Assigns based on capacity and workload',
|
||||
assignmentOrder: 'capacity_based',
|
||||
conversationPriority: 'medium',
|
||||
enabled: true,
|
||||
inboxes: [mockInboxes[2], mockInboxes[3]],
|
||||
isFetchingInboxes: false,
|
||||
});
|
||||
|
||||
const emptyPolicy = withCount({
|
||||
id: 3,
|
||||
name: 'No Inboxes Yet',
|
||||
description: 'Policy with no assigned inboxes',
|
||||
assignmentOrder: 'manual',
|
||||
conversationPriority: 'low',
|
||||
enabled: false,
|
||||
inboxes: [],
|
||||
isFetchingInboxes: false,
|
||||
});
|
||||
|
||||
const onEdit = id => console.log('Edit policy:', id);
|
||||
const onDelete = id => console.log('Delete policy:', id);
|
||||
const onFetch = () => console.log('Fetch inboxes');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/AssignmentPolicyCard"
|
||||
:layout="{ type: 'grid', width: '1200px' }"
|
||||
>
|
||||
<Variant title="Three Cards (Two with inboxes, One empty)">
|
||||
<div class="p-4 bg-n-background">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<AssignmentPolicyCard
|
||||
v-bind="policyA"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@fetch-inboxes="onFetch"
|
||||
/>
|
||||
<AssignmentPolicyCard
|
||||
v-bind="policyB"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@fetch-inboxes="onFetch"
|
||||
/>
|
||||
<AssignmentPolicyCard
|
||||
v-bind="emptyPolicy"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@fetch-inboxes="onFetch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
import { formatToTitleCase } from 'dashboard/helper/commons';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import CardPopover from '../components/CardPopover.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: Number, required: true },
|
||||
name: { type: String, default: '' },
|
||||
description: { type: String, default: '' },
|
||||
assignmentOrder: { type: String, default: '' },
|
||||
conversationPriority: { type: String, default: '' },
|
||||
assignedInboxCount: { type: Number, default: 0 },
|
||||
enabled: { type: Boolean, default: false },
|
||||
inboxes: { type: Array, default: () => [] },
|
||||
isFetchingInboxes: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit', 'delete', 'fetchInboxes']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const inboxes = computed(() => {
|
||||
return props.inboxes.map(inbox => {
|
||||
return {
|
||||
name: inbox.name,
|
||||
id: inbox.id,
|
||||
icon: getInboxIconByType(inbox.channelType, inbox.medium, 'line'),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const order = computed(() => {
|
||||
return formatToTitleCase(props.assignmentOrder);
|
||||
});
|
||||
|
||||
const priority = computed(() => {
|
||||
return formatToTitleCase(props.conversationPriority);
|
||||
});
|
||||
|
||||
const handleEdit = () => {
|
||||
emit('edit', props.id);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
emit('delete', props.id);
|
||||
};
|
||||
|
||||
const handleFetchInboxes = () => {
|
||||
if (props.inboxes?.length > 0) return;
|
||||
emit('fetchInboxes', props.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout class="[&>div]:px-5">
|
||||
<div class="flex flex-col gap-2 relative justify-between w-full">
|
||||
<div class="flex items-center gap-3 justify-between w-full">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
|
||||
{{ name }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center rounded-md bg-n-alpha-2 h-6 px-2">
|
||||
<span
|
||||
class="text-xs"
|
||||
:class="enabled ? 'text-n-teal-11' : 'text-n-slate-12'"
|
||||
>
|
||||
{{
|
||||
enabled
|
||||
? t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ACTIVE'
|
||||
)
|
||||
: t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.INACTIVE'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<CardPopover
|
||||
:title="
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.POPOVER'
|
||||
)
|
||||
"
|
||||
icon="i-lucide-inbox"
|
||||
:count="assignedInboxCount"
|
||||
:items="inboxes"
|
||||
:is-fetching="isFetchingInboxes"
|
||||
@fetch="handleFetchInboxes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:label="
|
||||
t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.EDIT')
|
||||
"
|
||||
sm
|
||||
slate
|
||||
link
|
||||
class="px-2"
|
||||
@click="handleEdit"
|
||||
/>
|
||||
<div v-if="order" class="w-px h-2.5 bg-n-slate-5" />
|
||||
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
|
||||
{{ description }}
|
||||
</p>
|
||||
<div class="flex items-center gap-3 py-1.5">
|
||||
<span v-if="order" class="text-n-slate-11 text-sm">
|
||||
{{
|
||||
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ORDER')}:`
|
||||
}}
|
||||
<span class="text-n-slate-12">{{ order }}</span>
|
||||
</span>
|
||||
<div v-if="order" class="w-px h-3 bg-n-strong" />
|
||||
<span v-if="priority" class="text-n-slate-11 text-sm">
|
||||
{{
|
||||
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.PRIORITY')}:`
|
||||
}}
|
||||
<span class="text-n-slate-12">{{ priority }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</template>
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useToggle, useWindowSize, useElementBounding } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['add']);
|
||||
|
||||
const BUFFER_SPACE = 20;
|
||||
|
||||
const [showPopover, togglePopover] = useToggle();
|
||||
const buttonRef = ref();
|
||||
const dropdownRef = ref();
|
||||
|
||||
const searchValue = ref('');
|
||||
|
||||
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
||||
const {
|
||||
top: buttonTop,
|
||||
left: buttonLeft,
|
||||
width: buttonWidth,
|
||||
height: buttonHeight,
|
||||
} = useElementBounding(buttonRef);
|
||||
const { width: dropdownWidth, height: dropdownHeight } =
|
||||
useElementBounding(dropdownRef);
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
if (!searchValue.value) return props.items;
|
||||
const query = searchValue.value.toLowerCase();
|
||||
|
||||
return picoSearch(props.items, query, ['name']);
|
||||
});
|
||||
|
||||
const handleAdd = item => {
|
||||
emit('add', item);
|
||||
togglePopover(false);
|
||||
};
|
||||
|
||||
const shouldShowAbove = computed(() => {
|
||||
if (!buttonRef.value || !dropdownRef.value) return false;
|
||||
const spaceBelow =
|
||||
windowHeight.value - (buttonTop.value + buttonHeight.value);
|
||||
const spaceAbove = buttonTop.value;
|
||||
return (
|
||||
spaceBelow < dropdownHeight.value + BUFFER_SPACE && spaceAbove > spaceBelow
|
||||
);
|
||||
});
|
||||
|
||||
const shouldAlignRight = computed(() => {
|
||||
if (!buttonRef.value || !dropdownRef.value) return false;
|
||||
const spaceRight = windowWidth.value - buttonLeft.value;
|
||||
const spaceLeft = buttonLeft.value + buttonWidth.value;
|
||||
|
||||
return (
|
||||
spaceRight < dropdownWidth.value + BUFFER_SPACE && spaceLeft > spaceRight
|
||||
);
|
||||
});
|
||||
|
||||
const handleClickOutside = () => {
|
||||
if (showPopover.value) {
|
||||
togglePopover(false);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="handleClickOutside"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
ref="buttonRef"
|
||||
slate
|
||||
type="button"
|
||||
icon="i-lucide-plus"
|
||||
sm
|
||||
:label="label"
|
||||
@click="togglePopover(!showPopover)"
|
||||
/>
|
||||
<div
|
||||
v-if="showPopover"
|
||||
ref="dropdownRef"
|
||||
class="z-50 flex flex-col items-start absolute bg-n-alpha-3 backdrop-blur-[50px] border-0 gap-4 outline outline-1 outline-n-weak rounded-xl max-w-96 min-w-80 max-h-[20rem] overflow-y-auto py-2"
|
||||
:class="[
|
||||
shouldShowAbove ? 'bottom-full mb-2' : 'top-full mt-2',
|
||||
shouldAlignRight ? 'right-0' : 'left-0',
|
||||
]"
|
||||
>
|
||||
<div class="flex flex-col divide-y divide-n-slate-4 w-full">
|
||||
<Input
|
||||
v-model="searchValue"
|
||||
:placeholder="searchPlaceholder"
|
||||
custom-input-class="bg-transparent !outline-none w-full ltr:!pl-10 rtl:!pr-10 h-10"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-3 rtl:right-3"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
|
||||
<div
|
||||
v-for="item in filteredItems"
|
||||
:key="item.id"
|
||||
class="flex gap-3 min-w-0 w-full py-4 px-3 hover:bg-n-alpha-2 cursor-pointer"
|
||||
:class="{ 'items-center': item.color, 'items-start': !item.color }"
|
||||
@click="handleAdd(item)"
|
||||
>
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="size-4 text-n-slate-12 flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<span
|
||||
v-else-if="item.color"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
class="size-3 rounded-sm"
|
||||
/>
|
||||
<Avatar
|
||||
v-else
|
||||
:title="item.name"
|
||||
:src="item.avatarUrl"
|
||||
:name="item.name"
|
||||
:size="20"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="flex flex-col items-start gap-2 min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1 min-w-0 w-full">
|
||||
<span
|
||||
:title="item.name || item.title"
|
||||
class="text-sm text-n-slate-12 truncate min-w-0 flex-1"
|
||||
>
|
||||
{{ item.name || item.title }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="item.email || item.phoneNumber"
|
||||
:title="item.email || item.phoneNumber"
|
||||
class="text-sm text-n-slate-11 truncate min-w-0 w-full block"
|
||||
>
|
||||
{{ item.email || item.phoneNumber }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
|
||||
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
defineProps({
|
||||
nameLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
namePlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
descriptionLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
descriptionPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
statusLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
statusPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['validationChange']);
|
||||
|
||||
const policyName = defineModel('policyName', {
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const description = defineModel('description', {
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const enabled = defineModel('enabled', {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
});
|
||||
|
||||
const validationRules = {
|
||||
policyName: { required, minLength: minLength(1) },
|
||||
description: { required, minLength: minLength(1) },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validationRules, { policyName, description });
|
||||
|
||||
const isValid = computed(() => !v$.value.$invalid);
|
||||
|
||||
watch(
|
||||
isValid,
|
||||
() => {
|
||||
emit('validationChange', {
|
||||
isValid: isValid.value,
|
||||
section: 'baseInfo',
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 pb-4">
|
||||
<!-- Policy Name Field -->
|
||||
<div class="flex items-center gap-6">
|
||||
<WithLabel
|
||||
:label="nameLabel"
|
||||
name="policyName"
|
||||
class="flex items-center w-full [&>label]:min-w-[120px]"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<Input
|
||||
v-model="policyName"
|
||||
type="text"
|
||||
:placeholder="namePlaceholder"
|
||||
/>
|
||||
</div>
|
||||
</WithLabel>
|
||||
</div>
|
||||
|
||||
<!-- Description Field -->
|
||||
<div class="flex items-center gap-6">
|
||||
<WithLabel
|
||||
:label="descriptionLabel"
|
||||
name="description"
|
||||
class="flex items-center w-full [&>label]:min-w-[120px]"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<Input
|
||||
v-model="description"
|
||||
type="text"
|
||||
:placeholder="descriptionPlaceholder"
|
||||
/>
|
||||
</div>
|
||||
</WithLabel>
|
||||
</div>
|
||||
|
||||
<!-- Status Field -->
|
||||
<div v-if="statusLabel" class="flex items-center gap-6">
|
||||
<WithLabel
|
||||
:label="statusLabel"
|
||||
name="enabled"
|
||||
class="flex items-center w-full [&>label]:min-w-[120px]"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch v-model="enabled" />
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ statusPlaceholder }}
|
||||
</span>
|
||||
</div>
|
||||
</WithLabel>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup>
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
defineProps({
|
||||
count: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isFetching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['fetch']);
|
||||
|
||||
const [showPopover, togglePopover] = useToggle();
|
||||
|
||||
const handleButtonClick = () => {
|
||||
emit('fetch');
|
||||
togglePopover(!showPopover.value);
|
||||
};
|
||||
|
||||
const handleClickOutside = () => {
|
||||
if (showPopover.value) {
|
||||
togglePopover(false);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="handleClickOutside"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<button
|
||||
v-if="count"
|
||||
class="h-6 px-2 rounded-md bg-n-alpha-2 gap-1.5 flex items-center"
|
||||
@click="handleButtonClick()"
|
||||
>
|
||||
<Icon :icon="icon" class="size-3.5 text-n-slate-12" />
|
||||
<span class="text-n-slate-12 text-sm">
|
||||
{{ count }}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="showPopover"
|
||||
class="top-full mt-1 ltr:left-0 rtl:right-0 z-50 flex flex-col items-start absolute bg-n-alpha-3 backdrop-blur-[50px] border-0 gap-4 outline outline-1 outline-n-weak p-3 rounded-xl max-w-96 min-w-80 max-h-[20rem] overflow-y-auto"
|
||||
>
|
||||
<div class="flex items-center gap-2.5 pb-2">
|
||||
<Icon :icon="icon" class="size-3.5" />
|
||||
<span class="text-sm text-n-slate-12 font-medium">{{ title }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex items-center justify-center py-3 w-full text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-4 w-full">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="flex items-center justify-between gap-2 min-w-0 w-full"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0 w-full">
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="size-4 text-n-slate-12 flex-shrink-0"
|
||||
/>
|
||||
<Avatar
|
||||
v-else
|
||||
:title="item.name"
|
||||
:src="item.avatarUrl"
|
||||
:name="item.name"
|
||||
:size="20"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<span
|
||||
:title="item.name"
|
||||
class="text-sm text-n-slate-12 truncate min-w-0"
|
||||
>
|
||||
{{ item.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.id"
|
||||
class="text-sm text-n-slate-11 flex-shrink-0"
|
||||
>
|
||||
{{ `#${item.id}` }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="item.email" class="text-sm text-n-slate-11 flex-shrink-0">
|
||||
{{ item.email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup>
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isFetching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
emptyStateMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['delete']);
|
||||
|
||||
const handleDelete = itemId => {
|
||||
emit('delete', itemId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex items-center justify-center py-3 w-full text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="items.length === 0 && emptyStateMessage"
|
||||
class="custom-dashed-border flex items-center justify-center py-6 w-full"
|
||||
>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ emptyStateMessage }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="flex flex-col divide-y divide-n-weak">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="grid grid-cols-4 items-center gap-3 min-w-0 w-full justify-between h-[3.25rem] ltr:pr-2 rtl:pl-2"
|
||||
>
|
||||
<div class="flex items-center gap-2 col-span-2">
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="size-4 text-n-slate-12 flex-shrink-0"
|
||||
/>
|
||||
<Avatar
|
||||
v-else
|
||||
:title="item.name"
|
||||
:src="item.avatarUrl"
|
||||
:name="item.name"
|
||||
:size="20"
|
||||
rounded-full
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12 truncate min-w-0">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-2 col-span-1">
|
||||
<span
|
||||
:title="item.email || item.phoneNumber"
|
||||
class="text-sm text-n-slate-12 truncate min-w-0"
|
||||
>
|
||||
{{ item.email || item.phoneNumber }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1 justify-end flex items-center">
|
||||
<Button
|
||||
icon="i-lucide-trash"
|
||||
slate
|
||||
ghost
|
||||
sm
|
||||
type="button"
|
||||
@click="handleDelete(item.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
|
||||
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
|
||||
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
||||
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
|
||||
|
||||
const props = defineProps({
|
||||
tagsList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const excludedLabels = defineModel('excludedLabels', {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
});
|
||||
|
||||
const excludeOlderThanMinutes = defineModel('excludeOlderThanMinutes', {
|
||||
type: Number,
|
||||
default: 10,
|
||||
});
|
||||
|
||||
// Duration limits: 10 minutes to 999 days (in minutes)
|
||||
const MIN_DURATION_MINUTES = 10;
|
||||
const MAX_DURATION_MINUTES = 1438560; // 999 days * 24 hours * 60 minutes
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const hoveredLabel = ref(null);
|
||||
const windowUnit = ref(DURATION_UNITS.MINUTES);
|
||||
|
||||
const addedTags = computed(() =>
|
||||
props.tagsList
|
||||
.filter(label => excludedLabels.value.includes(label.name))
|
||||
.map(label => ({ id: label.id, title: label.name, ...label }))
|
||||
);
|
||||
|
||||
const filteredTags = computed(() =>
|
||||
props.tagsList.filter(
|
||||
label => !addedTags.value.some(tag => tag.id === label.id)
|
||||
)
|
||||
);
|
||||
|
||||
const detectUnit = minutes => {
|
||||
const m = Number(minutes) || 0;
|
||||
if (m === 0) return DURATION_UNITS.MINUTES;
|
||||
if (m % (24 * 60) === 0) return DURATION_UNITS.DAYS;
|
||||
if (m % 60 === 0) return DURATION_UNITS.HOURS;
|
||||
return DURATION_UNITS.MINUTES;
|
||||
};
|
||||
|
||||
const onClickAddTag = tag => {
|
||||
excludedLabels.value = [...excludedLabels.value, tag.name];
|
||||
};
|
||||
|
||||
const onClickRemoveTag = tag => {
|
||||
excludedLabels.value = excludedLabels.value.filter(
|
||||
name => name !== tag.title
|
||||
);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
windowUnit.value = detectUnit(excludeOlderThanMinutes.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="py-4 flex-col flex gap-6">
|
||||
<div class="flex flex-col items-start gap-1 py-1">
|
||||
<label class="text-sm font-medium text-n-slate-12 py-1">
|
||||
{{
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.LABEL'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<p class="mb-0 text-n-slate-11 text-sm">
|
||||
{{
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.DESCRIPTION'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start gap-4">
|
||||
<label class="text-sm font-medium text-n-slate-12 py-1">
|
||||
{{
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.TAGS.LABEL'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<div
|
||||
class="flex items-start gap-2 flex-wrap"
|
||||
@mouseleave="hoveredLabel = null"
|
||||
>
|
||||
<LabelItem
|
||||
v-for="tag in addedTags"
|
||||
:key="tag.id"
|
||||
:label="tag"
|
||||
:is-hovered="hoveredLabel === tag.id"
|
||||
class="h-8"
|
||||
@remove="onClickRemoveTag"
|
||||
@hover="hoveredLabel = tag.id"
|
||||
/>
|
||||
<AddDataDropdown
|
||||
:label="
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.TAGS.ADD_TAG'
|
||||
)
|
||||
"
|
||||
:search-placeholder="
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.TAGS.DROPDOWN.SEARCH_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
:items="filteredTags"
|
||||
class="[&>button]:!text-n-blue-text [&>div]:min-w-64"
|
||||
@add="onClickAddTag"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start gap-4">
|
||||
<label class="text-sm font-medium text-n-slate-12 py-1">
|
||||
{{
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.DURATION.LABEL'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<div
|
||||
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
|
||||
>
|
||||
<!-- allow 10 mins to 999 days -->
|
||||
<DurationInput
|
||||
v-model:unit="windowUnit"
|
||||
v-model:model-value="excludeOlderThanMinutes"
|
||||
:min="MIN_DURATION_MINUTES"
|
||||
:max="MAX_DURATION_MINUTES"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
||||
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const fairDistributionLimit = defineModel('fairDistributionLimit', {
|
||||
type: Number,
|
||||
default: 100,
|
||||
set(value) {
|
||||
return Number(value) || 0;
|
||||
},
|
||||
});
|
||||
|
||||
const fairDistributionWindow = defineModel('fairDistributionWindow', {
|
||||
type: Number,
|
||||
default: 3600,
|
||||
set(value) {
|
||||
return Number(value) || 0;
|
||||
},
|
||||
});
|
||||
|
||||
const windowUnit = ref(DURATION_UNITS.MINUTES);
|
||||
|
||||
const detectUnit = minutes => {
|
||||
const m = Number(minutes) || 0;
|
||||
if (m === 0) return DURATION_UNITS.MINUTES;
|
||||
if (m % (24 * 60) === 0) return DURATION_UNITS.DAYS;
|
||||
if (m % 60 === 0) return DURATION_UNITS.HOURS;
|
||||
return DURATION_UNITS.MINUTES;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
windowUnit.value = detectUnit(fairDistributionWindow.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-start xl:items-center flex-col md:flex-row gap-4 lg:gap-3 bg-n-solid-1 p-4 outline outline-1 outline-n-weak rounded-xl"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.FAIR_DISTRIBUTION.INPUT_MAX'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
<Input
|
||||
v-model="fairDistributionLimit"
|
||||
type="number"
|
||||
placeholder="100"
|
||||
max="100000"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex sm:flex-row flex-col items-start sm:items-center gap-4">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.FAIR_DISTRIBUTION.DURATION'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
|
||||
>
|
||||
<!-- allow 10 mins to 999 days -->
|
||||
<DurationInput
|
||||
v-model:model-value="fairDistributionWindow"
|
||||
v-model:unit="windowUnit"
|
||||
:min="10"
|
||||
:max="1438560"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inboxList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isFetching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['delete', 'add', 'update']);
|
||||
|
||||
const inboxCapacityLimits = defineModel('inboxCapacityLimits', {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY';
|
||||
const DEFAULT_CONVERSATION_LIMIT = 10;
|
||||
const MIN_CONVERSATION_LIMIT = 1;
|
||||
const MAX_CONVERSATION_LIMIT = 100000;
|
||||
|
||||
const selectedInboxIds = computed(
|
||||
() => new Set(inboxCapacityLimits.value.map(limit => limit.inboxId))
|
||||
);
|
||||
|
||||
const availableInboxes = computed(() =>
|
||||
props.inboxList.filter(
|
||||
inbox => inbox && !selectedInboxIds.value.has(inbox.id)
|
||||
)
|
||||
);
|
||||
|
||||
const isLimitValid = limit => {
|
||||
return (
|
||||
limit.conversationLimit >= MIN_CONVERSATION_LIMIT &&
|
||||
limit.conversationLimit <= MAX_CONVERSATION_LIMIT
|
||||
);
|
||||
};
|
||||
|
||||
const inboxMap = computed(
|
||||
() => new Map(props.inboxList.map(inbox => [inbox.id, inbox]))
|
||||
);
|
||||
|
||||
const handleAddInbox = inbox => {
|
||||
emit('add', {
|
||||
inboxId: inbox.id,
|
||||
conversationLimit: DEFAULT_CONVERSATION_LIMIT,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveLimit = limitId => {
|
||||
emit('delete', limitId);
|
||||
};
|
||||
|
||||
const handleLimitChange = limit => {
|
||||
if (isLimitValid(limit)) {
|
||||
emit('update', limit);
|
||||
}
|
||||
};
|
||||
|
||||
const getInboxName = inboxId => {
|
||||
return inboxMap.value.get(inboxId)?.name || '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="py-4 flex-col flex gap-3">
|
||||
<div class="flex items-center w-full gap-8 justify-between pt-1 pb-3">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.LABEL`) }}
|
||||
</label>
|
||||
|
||||
<AddDataDropdown
|
||||
:label="t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.ADD_BUTTON`)"
|
||||
:search-placeholder="
|
||||
t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.SELECT_INBOX`)
|
||||
"
|
||||
:items="availableInboxes"
|
||||
@add="handleAddInbox"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex items-center justify-center py-3 w-full text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!inboxCapacityLimits.length"
|
||||
class="custom-dashed-border flex items-center justify-center py-6 w-full"
|
||||
>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.EMPTY_STATE`) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-col flex gap-3">
|
||||
<div
|
||||
v-for="(limit, index) in inboxCapacityLimits"
|
||||
:key="limit.id || `temp-${index}`"
|
||||
class="flex flex-col xs:flex-row items-stretch gap-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center rounded-lg outline-1 outline cursor-not-allowed text-n-slate-11 outline-n-weak py-2.5 px-3 text-sm w-full min-w-0"
|
||||
:title="getInboxName(limit.inboxId)"
|
||||
>
|
||||
<span class="truncate min-w-0">
|
||||
{{ getInboxName(limit.inboxId) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 w-full xs:w-auto">
|
||||
<div
|
||||
class="py-2.5 px-3 rounded-lg gap-2 outline outline-1 flex-1 xs:flex-shrink-0 flex items-center min-w-0"
|
||||
:class="[
|
||||
!isLimitValid(limit) ? 'outline-n-ruby-8' : 'outline-n-weak',
|
||||
]"
|
||||
>
|
||||
<label
|
||||
class="text-sm text-n-slate-12 ltr:pr-2 rtl:pl-2 truncate min-w-0 flex-shrink"
|
||||
:title="
|
||||
t(
|
||||
`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.MAX_CONVERSATIONS`
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.MAX_CONVERSATIONS`
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
|
||||
<div class="h-5 w-px bg-n-weak" />
|
||||
|
||||
<input
|
||||
v-model.number="limit.conversationLimit"
|
||||
type="number"
|
||||
:min="MIN_CONVERSATION_LIMIT"
|
||||
:max="MAX_CONVERSATION_LIMIT"
|
||||
class="reset-base bg-transparent focus:outline-none min-w-16 w-24 text-sm flex-shrink-0"
|
||||
:class="[
|
||||
!isLimitValid(limit)
|
||||
? 'placeholder:text-n-ruby-9 !text-n-ruby-9'
|
||||
: 'placeholder:text-n-slate-10 text-n-slate-12',
|
||||
]"
|
||||
:placeholder="
|
||||
t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.SET_LIMIT`)
|
||||
"
|
||||
@blur="handleLimitChange(limit)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
slate
|
||||
icon="i-lucide-trash"
|
||||
class="flex-shrink-0"
|
||||
@click="handleRemoveLimit(limit.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const handleChange = () => {
|
||||
if (!props.isActive) {
|
||||
emit('select', props.id);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
|
||||
:class="[
|
||||
isActive ? 'outline-n-blue-9' : 'outline-n-weak hover:outline-n-strong',
|
||||
]"
|
||||
@click="handleChange"
|
||||
>
|
||||
<div class="absolute top-4 right-4">
|
||||
<input
|
||||
:id="`${id}`"
|
||||
:checked="isActive"
|
||||
:value="id"
|
||||
:name="id"
|
||||
type="radio"
|
||||
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex flex-col gap-3 items-start">
|
||||
<h3 class="text-sm font-medium text-n-slate-12">
|
||||
{{ label }}
|
||||
</h3>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
<script setup>
|
||||
import AddDataDropdown from '../AddDataDropdown.vue';
|
||||
|
||||
const mockInboxes = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Website Support',
|
||||
email: 'support@company.com',
|
||||
icon: 'i-lucide-globe',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Email Support',
|
||||
email: 'help@company.com',
|
||||
icon: 'i-lucide-mail',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'WhatsApp Business',
|
||||
phoneNumber: '+1 555-0123',
|
||||
icon: 'i-lucide-message-circle',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Facebook Messenger',
|
||||
email: 'messenger@company.com',
|
||||
icon: 'i-lucide-facebook',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Twitter DM',
|
||||
email: 'twitter@company.com',
|
||||
icon: 'i-lucide-twitter',
|
||||
},
|
||||
];
|
||||
|
||||
const mockTags = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'urgent',
|
||||
color: '#ff4757',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'bug',
|
||||
color: '#ff6b6b',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'feature-request',
|
||||
color: '#4834d4',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'documentation',
|
||||
color: '#26de81',
|
||||
},
|
||||
];
|
||||
|
||||
const handleAdd = item => {
|
||||
console.log('Add item:', item);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/AddDataDropdown"
|
||||
:layout="{ type: 'grid', width: '500px' }"
|
||||
>
|
||||
<Variant title="Basic Usage - Inboxes">
|
||||
<div class="p-8 bg-n-background flex gap-4 h-[400px] items-start">
|
||||
<AddDataDropdown
|
||||
label="Add Inbox"
|
||||
search-placeholder="Search inboxes..."
|
||||
:items="mockInboxes"
|
||||
@add="handleAdd"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Basic Usage - Tags">
|
||||
<div class="p-8 bg-n-background flex gap-4 h-[400px] items-start">
|
||||
<AddDataDropdown
|
||||
label="Add Tag"
|
||||
search-placeholder="Search tags..."
|
||||
:items="mockTags"
|
||||
@add="handleAdd"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import BaseInfo from '../BaseInfo.vue';
|
||||
|
||||
const policyName = ref('Round Robin Policy');
|
||||
const description = ref(
|
||||
'Distributes conversations evenly among available agents'
|
||||
);
|
||||
const enabled = ref(true);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/BaseInfo"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Basic Usage">
|
||||
<div class="p-8 bg-n-background">
|
||||
<BaseInfo
|
||||
v-model:policy-name="policyName"
|
||||
v-model:description="description"
|
||||
v-model:enabled="enabled"
|
||||
name-label="Policy Name"
|
||||
name-placeholder="Enter policy name"
|
||||
description-label="Description"
|
||||
description-placeholder="Enter policy description"
|
||||
status-label="Status"
|
||||
status-placeholder="Active"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
<script setup>
|
||||
import CardPopover from '../CardPopover.vue';
|
||||
|
||||
const mockItems = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Website Support',
|
||||
icon: 'i-lucide-globe',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Email Support',
|
||||
icon: 'i-lucide-mail',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'WhatsApp Business',
|
||||
icon: 'i-lucide-message-circle',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Facebook Messenger',
|
||||
icon: 'i-lucide-facebook',
|
||||
},
|
||||
];
|
||||
|
||||
const mockUsers = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'John Smith',
|
||||
email: 'john.smith@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Sarah Johnson',
|
||||
email: 'sarah.johnson@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=2',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Mike Chen',
|
||||
email: 'mike.chen@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=3',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Emily Davis',
|
||||
email: 'emily.davis@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=4',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Alex Rodriguez',
|
||||
email: 'alex.rodriguez@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=5',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/CardPopover"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Basic Usage">
|
||||
<div class="p-8 bg-n-background flex gap-4 h-96 items-start">
|
||||
<CardPopover
|
||||
:count="3"
|
||||
title="Added Inboxes"
|
||||
icon="i-lucide-inbox"
|
||||
:items="mockItems.slice(0, 3)"
|
||||
@fetch="() => console.log('Fetch triggered')"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Basic Usage">
|
||||
<div class="p-8 bg-n-background flex gap-4 h-96 items-start">
|
||||
<CardPopover
|
||||
:count="3"
|
||||
title="Added Agents"
|
||||
icon="i-lucide-users-round"
|
||||
:items="mockUsers.slice(0, 3)"
|
||||
@fetch="() => console.log('Fetch triggered')"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
<script setup>
|
||||
import DataTable from '../DataTable.vue';
|
||||
|
||||
const mockItems = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Website Support',
|
||||
email: 'support@company.com',
|
||||
icon: 'i-lucide-globe',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Email Support',
|
||||
email: 'help@company.com',
|
||||
icon: 'i-lucide-mail',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'WhatsApp Business',
|
||||
phoneNumber: '+1 555-0123',
|
||||
icon: 'i-lucide-message-circle',
|
||||
},
|
||||
];
|
||||
|
||||
const mockAgentList = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'John Doe',
|
||||
email: 'john.doe@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Jane Smith',
|
||||
email: 'jane.smith@example.com',
|
||||
avatarUrl: 'https://i.pravatar.cc/150?img=2',
|
||||
},
|
||||
];
|
||||
|
||||
const handleDelete = itemId => {
|
||||
console.log('Delete item:', itemId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/DataTable"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="With Data">
|
||||
<div class="p-8 bg-n-background">
|
||||
<DataTable
|
||||
:items="mockItems"
|
||||
:is-fetching="false"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With Agents">
|
||||
<div class="p-8 bg-n-background">
|
||||
<DataTable
|
||||
:items="mockAgentList"
|
||||
:is-fetching="false"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Loading State">
|
||||
<div class="p-8 bg-n-background">
|
||||
<DataTable :items="[]" is-fetching @delete="handleDelete" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Empty State">
|
||||
<div class="p-8 bg-n-background">
|
||||
<DataTable
|
||||
:items="[]"
|
||||
:is-fetching="false"
|
||||
empty-state-message="No items found"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
import ExclusionRules from '../ExclusionRules.vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const mockTagsList = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'urgent',
|
||||
color: '#ff4757',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'bug',
|
||||
color: '#ff6b6b',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'feature-request',
|
||||
color: '#4834d4',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'documentation',
|
||||
color: '#26de81',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'enhancement',
|
||||
color: '#2ed573',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'question',
|
||||
color: '#ffa502',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'duplicate',
|
||||
color: '#747d8c',
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'wontfix',
|
||||
color: '#57606f',
|
||||
},
|
||||
];
|
||||
|
||||
const excludedLabelsBasic = ref([]);
|
||||
const excludeOlderThanHoursBasic = ref(10);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/ExclusionRules"
|
||||
:layout="{ type: 'grid', width: '1200px' }"
|
||||
>
|
||||
<Variant title="Basic Usage">
|
||||
<div class="p-8 bg-n-background h-[600px]">
|
||||
<ExclusionRules
|
||||
v-model:excluded-labels="excludedLabelsBasic"
|
||||
v-model:exclude-older-than-minutes="excludeOlderThanHoursBasic"
|
||||
:tags-list="mockTagsList"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import FairDistribution from '../FairDistribution.vue';
|
||||
|
||||
const fairDistributionLimit = ref(100);
|
||||
const fairDistributionWindow = ref(3600);
|
||||
const windowUnit = ref('minutes');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/FairDistribution"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Basic Usage">
|
||||
<div class="p-8 bg-n-background">
|
||||
<FairDistribution
|
||||
v-model:fair-distribution-limit="fairDistributionLimit"
|
||||
v-model:fair-distribution-window="fairDistributionWindow"
|
||||
v-model:window-unit="windowUnit"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
<script setup>
|
||||
import InboxCapacityLimits from '../InboxCapacityLimits.vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const mockInboxList = [
|
||||
{
|
||||
value: 1,
|
||||
label: 'Website Support',
|
||||
icon: 'i-lucide-globe',
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
label: 'Email Support',
|
||||
icon: 'i-lucide-mail',
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
label: 'WhatsApp Business',
|
||||
icon: 'i-lucide-message-circle',
|
||||
},
|
||||
{
|
||||
value: 4,
|
||||
label: 'Facebook Messenger',
|
||||
icon: 'i-lucide-facebook',
|
||||
},
|
||||
{
|
||||
value: 5,
|
||||
label: 'Twitter DM',
|
||||
icon: 'i-lucide-twitter',
|
||||
},
|
||||
{
|
||||
value: 6,
|
||||
label: 'Telegram',
|
||||
icon: 'i-lucide-send',
|
||||
},
|
||||
];
|
||||
|
||||
const inboxCapacityLimitsEmpty = ref([]);
|
||||
const inboxCapacityLimitsNew = ref([
|
||||
{ id: 1, inboxId: 1, conversationLimit: 5 },
|
||||
{ inboxId: null, conversationLimit: null },
|
||||
]);
|
||||
|
||||
const handleDelete = id => {
|
||||
console.log('Delete capacity limit:', id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/InboxCapacityLimits"
|
||||
:layout="{ type: 'grid', width: '900px' }"
|
||||
>
|
||||
<Variant title="Empty State">
|
||||
<div class="p-8 bg-n-background">
|
||||
<InboxCapacityLimits
|
||||
v-model:inbox-capacity-limits="inboxCapacityLimitsEmpty"
|
||||
:inbox-list="mockInboxList"
|
||||
:is-fetching="false"
|
||||
:is-updating="false"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Loading State">
|
||||
<div class="p-8 bg-n-background">
|
||||
<InboxCapacityLimits
|
||||
v-model:inbox-capacity-limits="inboxCapacityLimitsEmpty"
|
||||
:inbox-list="mockInboxList"
|
||||
is-fetching
|
||||
:is-updating="false"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With New Row and existing data">
|
||||
<div class="p-8 bg-n-background">
|
||||
<InboxCapacityLimits
|
||||
v-model:inbox-capacity-limits="inboxCapacityLimitsNew"
|
||||
:inbox-list="mockInboxList"
|
||||
:is-fetching="false"
|
||||
:is-updating="false"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Interactive Demo">
|
||||
<div class="p-8 bg-n-background">
|
||||
<InboxCapacityLimits
|
||||
v-model:inbox-capacity-limits="inboxCapacityLimitsEmpty"
|
||||
:inbox-list="mockInboxList"
|
||||
:is-fetching="false"
|
||||
:is-updating="false"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
<div class="mt-4 p-4 bg-n-alpha-2 rounded-lg">
|
||||
<h4 class="text-sm font-medium mb-2">Current Limits:</h4>
|
||||
<pre class="text-xs">{{
|
||||
JSON.stringify(inboxCapacityLimitsEmpty, null, 2)
|
||||
}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import RadioCard from '../RadioCard.vue';
|
||||
|
||||
const selectedOption = ref('round_robin');
|
||||
|
||||
const handleSelect = value => {
|
||||
selectedOption.value = value;
|
||||
console.log('Selected:', value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/AgentManagementPolicy/RadioCard"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Basic Usage">
|
||||
<div class="p-8 bg-n-background space-y-4">
|
||||
<RadioCard
|
||||
id="round_robin"
|
||||
label="Round Robin"
|
||||
description="Distributes conversations evenly among all available agents in a rotating manner"
|
||||
:is-active="selectedOption === 'round_robin'"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
<RadioCard
|
||||
id="balanced"
|
||||
label="Balanced Assignment"
|
||||
description="Assigns conversations based on agent workload to maintain balance"
|
||||
:is-active="selectedOption === 'balanced'"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Active State">
|
||||
<div class="p-8 bg-n-background">
|
||||
<RadioCard
|
||||
id="active_option"
|
||||
label="Active Option"
|
||||
description="This option is currently selected and active"
|
||||
is-active
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Inactive State">
|
||||
<div class="p-8 bg-n-background">
|
||||
<RadioCard
|
||||
id="inactive_option"
|
||||
label="Inactive Option"
|
||||
description="This option is not selected and can be clicked to activate"
|
||||
is-active
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -76,8 +76,8 @@ const campaignStatus = computed(() => {
|
||||
const inboxName = computed(() => props.inbox?.name || '');
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
const { phone_number: phoneNumber, channel_type: type } = props.inbox;
|
||||
return getInboxIconByType(type, phoneNumber);
|
||||
const { medium, channel_type: type } = props.inbox;
|
||||
return getInboxIconByType(type, medium);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import { ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT } from './CampaignEmptyStateContent';
|
||||
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EmptyStateLayout :title="title" :subtitle="subtitle">
|
||||
<template #empty-state-item>
|
||||
<div class="flex flex-col gap-4 p-px">
|
||||
<CampaignCard
|
||||
v-for="campaign in ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT"
|
||||
:key="campaign.id"
|
||||
:title="campaign.title"
|
||||
:message="campaign.message"
|
||||
:is-enabled="campaign.enabled"
|
||||
:status="campaign.campaign_status"
|
||||
:sender="campaign.sender"
|
||||
:inbox="campaign.inbox"
|
||||
:scheduled-at="campaign.scheduled_at"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
</template>
|
||||
+2
-2
@@ -40,9 +40,9 @@ const handleSubmit = campaignDetails => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6 max-h-[85vh] overflow-y-auto"
|
||||
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6 max-h-[85vh] overflow-y-auto"
|
||||
>
|
||||
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
|
||||
<h3 class="text-base font-medium text-n-slate-12">
|
||||
{{ t(`CAMPAIGN.LIVE_CHAT.CREATE.TITLE`) }}
|
||||
</h3>
|
||||
<LiveChatCampaignForm
|
||||
|
||||
+1
-1
@@ -306,7 +306,7 @@ defineExpose({ prepareCampaignDetails, isSubmitDisabled });
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.BUTTONS.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
|
||||
+2
-2
@@ -39,9 +39,9 @@ const handleClose = () => emit('close');
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6"
|
||||
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6"
|
||||
>
|
||||
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
|
||||
<h3 class="text-base font-medium text-n-slate-12">
|
||||
{{ t(`CAMPAIGN.SMS.CREATE.TITLE`) }}
|
||||
</h3>
|
||||
<SMSCampaignForm @submit="handleSubmit" @cancel="handleClose" />
|
||||
|
||||
+1
-1
@@ -174,7 +174,7 @@ const handleSubmit = async () => {
|
||||
color="slate"
|
||||
type="button"
|
||||
:label="t('CAMPAIGN.SMS.CREATE.FORM.BUTTONS.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
|
||||
import { CAMPAIGNS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events.js';
|
||||
|
||||
import WhatsAppCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const addCampaign = async campaignDetails => {
|
||||
try {
|
||||
await store.dispatch('campaigns/create', campaignDetails);
|
||||
|
||||
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
|
||||
type: CAMPAIGN_TYPES.ONE_OFF,
|
||||
});
|
||||
|
||||
useAlert(t('CAMPAIGN.WHATSAPP.CREATE.FORM.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.response?.message ||
|
||||
t('CAMPAIGN.WHATSAPP.CREATE.FORM.API.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = campaignDetails => {
|
||||
addCampaign(campaignDetails);
|
||||
};
|
||||
|
||||
const handleClose = () => emit('close');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] rounded-xl border border-n-weak shadow-md max-h-[80vh] overflow-y-auto"
|
||||
>
|
||||
<div class="p-6 flex flex-col gap-6">
|
||||
<h3 class="text-base font-medium text-n-slate-12 flex-shrink-0">
|
||||
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
|
||||
</h3>
|
||||
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
<script setup>
|
||||
import { reactive, computed, watch, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
|
||||
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const formState = {
|
||||
uiFlags: useMapGetter('campaigns/getUIFlags'),
|
||||
labels: useMapGetter('labels/getLabels'),
|
||||
inboxes: useMapGetter('inboxes/getWhatsAppInboxes'),
|
||||
getFilteredWhatsAppTemplates: useMapGetter(
|
||||
'inboxes/getFilteredWhatsAppTemplates'
|
||||
),
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
title: '',
|
||||
inboxId: null,
|
||||
templateId: null,
|
||||
scheduledAt: null,
|
||||
selectedAudience: [],
|
||||
};
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
const templateParserRef = ref(null);
|
||||
|
||||
const rules = {
|
||||
title: { required, minLength: minLength(1) },
|
||||
inboxId: { required },
|
||||
templateId: { required },
|
||||
scheduledAt: { required },
|
||||
selectedAudience: { required },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, state);
|
||||
|
||||
const isCreating = computed(() => formState.uiFlags.value.isCreating);
|
||||
|
||||
const currentDateTime = computed(() => {
|
||||
// Added to disable the scheduled at field from being set to the current time
|
||||
const now = new Date();
|
||||
const localTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
|
||||
return localTime.toISOString().slice(0, 16);
|
||||
});
|
||||
|
||||
const mapToOptions = (items, valueKey, labelKey) =>
|
||||
items?.map(item => ({
|
||||
value: item[valueKey],
|
||||
label: item[labelKey],
|
||||
})) ?? [];
|
||||
|
||||
const audienceList = computed(() =>
|
||||
mapToOptions(formState.labels.value, 'id', 'title')
|
||||
);
|
||||
|
||||
const inboxOptions = computed(() =>
|
||||
mapToOptions(formState.inboxes.value, 'id', 'name')
|
||||
);
|
||||
|
||||
const templateOptions = computed(() => {
|
||||
if (!state.inboxId) return [];
|
||||
const templates = formState.getFilteredWhatsAppTemplates.value(state.inboxId);
|
||||
return templates.map(template => {
|
||||
// Create a more user-friendly label from template name
|
||||
const friendlyName = template.name
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, l => l.toUpperCase());
|
||||
|
||||
return {
|
||||
value: template.id,
|
||||
label: `${friendlyName} (${template.language || 'en'})`,
|
||||
template: template,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const selectedTemplate = computed(() => {
|
||||
if (!state.templateId) return null;
|
||||
return templateOptions.value.find(option => option.value === state.templateId)
|
||||
?.template;
|
||||
});
|
||||
|
||||
const getErrorMessage = (field, errorKey) => {
|
||||
const baseKey = 'CAMPAIGN.WHATSAPP.CREATE.FORM';
|
||||
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
|
||||
};
|
||||
|
||||
const formErrors = computed(() => ({
|
||||
title: getErrorMessage('title', 'TITLE'),
|
||||
inbox: getErrorMessage('inboxId', 'INBOX'),
|
||||
template: getErrorMessage('templateId', 'TEMPLATE'),
|
||||
scheduledAt: getErrorMessage('scheduledAt', 'SCHEDULED_AT'),
|
||||
audience: getErrorMessage('selectedAudience', 'AUDIENCE'),
|
||||
}));
|
||||
|
||||
const hasRequiredTemplateParams = computed(() => {
|
||||
return templateParserRef.value?.v$?.$invalid === false || true;
|
||||
});
|
||||
|
||||
const isSubmitDisabled = computed(
|
||||
() => v$.value.$invalid || !hasRequiredTemplateParams.value
|
||||
);
|
||||
|
||||
const formatToUTCString = localDateTime =>
|
||||
localDateTime ? new Date(localDateTime).toISOString() : null;
|
||||
|
||||
const resetState = () => {
|
||||
Object.assign(state, initialState);
|
||||
v$.value.$reset();
|
||||
};
|
||||
|
||||
const handleCancel = () => emit('cancel');
|
||||
|
||||
const prepareCampaignDetails = () => {
|
||||
// Find the selected template to get its content
|
||||
const currentTemplate = selectedTemplate.value;
|
||||
const parserData = templateParserRef.value;
|
||||
|
||||
// Extract template content - this should be the template message body
|
||||
const templateContent = parserData?.renderedTemplate || '';
|
||||
|
||||
// Prepare template_params object with the same structure as used in contacts
|
||||
const templateParams = {
|
||||
name: currentTemplate?.name || '',
|
||||
namespace: currentTemplate?.namespace || '',
|
||||
category: currentTemplate?.category || 'UTILITY',
|
||||
language: currentTemplate?.language || 'en_US',
|
||||
processed_params: parserData?.processedParams || {},
|
||||
};
|
||||
|
||||
return {
|
||||
title: state.title,
|
||||
message: templateContent,
|
||||
template_params: templateParams,
|
||||
inbox_id: state.inboxId,
|
||||
scheduled_at: formatToUTCString(state.scheduledAt),
|
||||
audience: state.selectedAudience?.map(id => ({
|
||||
id,
|
||||
type: 'Label',
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const isFormValid = await v$.value.$validate();
|
||||
if (!isFormValid) return;
|
||||
|
||||
emit('submit', prepareCampaignDetails());
|
||||
resetState();
|
||||
handleCancel();
|
||||
};
|
||||
|
||||
// Reset template selection when inbox changes
|
||||
watch(
|
||||
() => state.inboxId,
|
||||
() => {
|
||||
state.templateId = null;
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<Input
|
||||
v-model="state.title"
|
||||
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TITLE.LABEL')"
|
||||
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TITLE.PLACEHOLDER')"
|
||||
:message="formErrors.title"
|
||||
:message-type="formErrors.title ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.INBOX.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
id="inbox"
|
||||
v-model="state.inboxId"
|
||||
:options="inboxOptions"
|
||||
:has-error="!!formErrors.inbox"
|
||||
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.INBOX.PLACEHOLDER')"
|
||||
:message="formErrors.inbox"
|
||||
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="template" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
id="template"
|
||||
v-model="state.templateId"
|
||||
:options="templateOptions"
|
||||
:has-error="!!formErrors.template"
|
||||
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.PLACEHOLDER')"
|
||||
:message="formErrors.template"
|
||||
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-n-slate-11">
|
||||
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.INFO') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Template Parser -->
|
||||
<WhatsAppTemplateParser
|
||||
v-if="selectedTemplate"
|
||||
ref="templateParserRef"
|
||||
:template="selectedTemplate"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.LABEL') }}
|
||||
</label>
|
||||
<TagMultiSelectComboBox
|
||||
v-model="state.selectedAudience"
|
||||
:options="audienceList"
|
||||
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.LABEL')"
|
||||
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.PLACEHOLDER')"
|
||||
:has-error="!!formErrors.audience"
|
||||
:message="formErrors.audience"
|
||||
class="[&>div>button]:bg-n-alpha-black2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
v-model="state.scheduledAt"
|
||||
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.SCHEDULED_AT.LABEL')"
|
||||
type="datetime-local"
|
||||
:min="currentDateTime"
|
||||
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.SCHEDULED_AT.PLACEHOLDER')"
|
||||
:message="formErrors.scheduledAt"
|
||||
:message-type="formErrors.scheduledAt ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<div class="flex gap-3 justify-between items-center w-full">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
type="button"
|
||||
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CREATE')"
|
||||
class="w-full"
|
||||
type="submit"
|
||||
:is-loading="isCreating"
|
||||
:disabled="isCreating || isSubmitDisabled"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -86,8 +86,8 @@ const handleLabelAction = async ({ value }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveLabel = labelId => {
|
||||
return handleLabelAction({ value: labelId });
|
||||
const handleRemoveLabel = label => {
|
||||
return handleLabelAction({ value: label.id });
|
||||
};
|
||||
|
||||
watch(
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
||||
|
||||
defineProps({
|
||||
selectedContact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showDeleteSection, toggleDeleteSection] = useToggle();
|
||||
const confirmDeleteContactDialogRef = ref(null);
|
||||
|
||||
const openConfirmDeleteContactDialog = () => {
|
||||
confirmDeleteContactDialogRef.value?.dialogRef.open();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||
sm
|
||||
link
|
||||
slate
|
||||
class="hover:!no-underline text-n-slate-12"
|
||||
icon="i-lucide-chevron-down"
|
||||
trailing-icon
|
||||
@click="toggleDeleteSection()"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
|
||||
:class="
|
||||
showDeleteSection
|
||||
? 'grid-rows-[1fr] opacity-100 mt-2'
|
||||
: 'grid-rows-[0fr] opacity-0 mt-0'
|
||||
"
|
||||
>
|
||||
<div class="overflow-hidden min-h-0">
|
||||
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
|
||||
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
|
||||
sm
|
||||
ruby
|
||||
link
|
||||
@click="openConfirmDeleteContactDialog()"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmContactDeleteDialog
|
||||
ref="confirmDeleteContactDialogRef"
|
||||
:selected-contact="selectedContact"
|
||||
/>
|
||||
</template>
|
||||
@@ -7,6 +7,7 @@ import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/Contac
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Flag from 'dashboard/components-next/flag/Flag.vue';
|
||||
import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue';
|
||||
import countries from 'shared/constants/countries';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -16,6 +17,7 @@ const props = defineProps({
|
||||
additionalAttributes: { type: Object, default: () => ({}) },
|
||||
phoneNumber: { type: String, default: '' },
|
||||
thumbnail: { type: String, default: '' },
|
||||
availabilityStatus: { type: String, default: null },
|
||||
isExpanded: { type: Boolean, default: false },
|
||||
isUpdating: { type: Boolean, default: false },
|
||||
});
|
||||
@@ -91,7 +93,14 @@ const onClickViewDetails = () => emit('showContact', props.id);
|
||||
<template>
|
||||
<CardLayout :key="id" layout="row">
|
||||
<div class="flex items-center justify-start flex-1 gap-4">
|
||||
<Avatar :name="name" :src="thumbnail" :size="48" rounded-full />
|
||||
<Avatar
|
||||
:name="name"
|
||||
:src="thumbnail"
|
||||
:size="48"
|
||||
:status="availabilityStatus"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<span class="text-base font-medium truncate text-n-slate-12">
|
||||
@@ -149,15 +158,15 @@ const onClickViewDetails = () => emit('showContact', props.id);
|
||||
/>
|
||||
|
||||
<template #after>
|
||||
<transition
|
||||
enter-active-class="overflow-hidden transition-all duration-300 ease-out"
|
||||
leave-active-class="overflow-hidden transition-all duration-300 ease-in"
|
||||
enter-from-class="overflow-hidden opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
|
||||
leave-from-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
|
||||
leave-to-class="overflow-hidden opacity-0 max-h-0"
|
||||
<div
|
||||
class="transition-all duration-500 ease-in-out grid overflow-hidden"
|
||||
:class="
|
||||
isExpanded
|
||||
? 'grid-rows-[1fr] opacity-100'
|
||||
: 'grid-rows-[0fr] opacity-0'
|
||||
"
|
||||
>
|
||||
<div v-show="isExpanded" class="w-full">
|
||||
<div class="overflow-hidden">
|
||||
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
|
||||
<ContactsForm
|
||||
ref="contactsFormRef"
|
||||
@@ -176,8 +185,14 @@ const onClickViewDetails = () => emit('showContact', props.id);
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ContactDeleteSection
|
||||
:selected-contact="{
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
</CardLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup>
|
||||
import { computed, useSlots } from 'vue';
|
||||
import { computed, useSlots, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||
import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedContact: {
|
||||
@@ -24,6 +26,8 @@ const { t } = useI18n();
|
||||
const slots = useSlots();
|
||||
const route = useRoute();
|
||||
|
||||
const isContactSidebarOpen = ref(false);
|
||||
|
||||
const contactId = computed(() => route.params.contactId);
|
||||
|
||||
const selectedContactName = computed(() => {
|
||||
@@ -56,6 +60,15 @@ const handleBreadcrumbClick = () => {
|
||||
const toggleBlock = () => {
|
||||
emit('toggleBlock', isContactBlocked.value);
|
||||
};
|
||||
|
||||
const handleConversationSidebarToggle = () => {
|
||||
isContactSidebarOpen.value = !isContactSidebarOpen.value;
|
||||
};
|
||||
|
||||
const closeMobileSidebar = () => {
|
||||
if (!isContactSidebarOpen.value) return;
|
||||
isContactSidebarOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -67,7 +80,9 @@ const toggleBlock = () => {
|
||||
>
|
||||
<header class="sticky top-0 z-10 px-6 3xl:px-0">
|
||||
<div class="w-full mx-auto max-w-[40.625rem]">
|
||||
<div class="flex items-center justify-between w-full h-20 gap-2">
|
||||
<div
|
||||
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
|
||||
>
|
||||
<Breadcrumb
|
||||
:items="breadcrumbItems"
|
||||
@click="handleBreadcrumbClick"
|
||||
@@ -85,6 +100,11 @@ const toggleBlock = () => {
|
||||
:disabled="isUpdating"
|
||||
@click="toggleBlock"
|
||||
/>
|
||||
<VoiceCallButton
|
||||
:phone="selectedContact?.phoneNumber"
|
||||
:label="$t('CONTACT_PANEL.CALL')"
|
||||
size="sm"
|
||||
/>
|
||||
<ComposeConversation :contact-id="contactId">
|
||||
<template #trigger="{ toggle }">
|
||||
<Button
|
||||
@@ -105,11 +125,65 @@ const toggleBlock = () => {
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Desktop sidebar -->
|
||||
<div
|
||||
v-if="slots.sidebar"
|
||||
class="overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
|
||||
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
|
||||
<!-- Mobile sidebar container -->
|
||||
<div
|
||||
v-if="slots.sidebar"
|
||||
class="lg:hidden fixed top-0 ltr:right-0 rtl:left-0 h-full z-50 flex justify-end transition-all duration-200 ease-in-out"
|
||||
:class="isContactSidebarOpen ? 'w-full' : 'w-16'"
|
||||
>
|
||||
<!-- Toggle button -->
|
||||
<div
|
||||
v-on-click-outside="[
|
||||
closeMobileSidebar,
|
||||
{ ignore: ['#contact-sidebar-content'] },
|
||||
]"
|
||||
class="flex items-start p-1 w-fit h-fit relative order-1 xs:top-24 top-28 transition-all bg-n-solid-2 border border-n-weak duration-500 ease-in-out"
|
||||
:class="[
|
||||
isContactSidebarOpen
|
||||
? 'justify-end ltr:rounded-l-full rtl:rounded-r-full ltr:rounded-r-none rtl:rounded-l-none'
|
||||
: 'justify-center rounded-full ltr:mr-6 rtl:ml-6',
|
||||
]"
|
||||
>
|
||||
<Button
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
class="!rounded-full rtl:rotate-180"
|
||||
:class="{ 'bg-n-alpha-2': isContactSidebarOpen }"
|
||||
:icon="
|
||||
isContactSidebarOpen
|
||||
? 'i-lucide-panel-right-close'
|
||||
: 'i-lucide-panel-right-open'
|
||||
"
|
||||
data-contact-sidebar-toggle
|
||||
@click="handleConversationSidebarToggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-transform duration-200 ease-in-out"
|
||||
leave-active-class="transition-transform duration-200 ease-in-out"
|
||||
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
|
||||
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
|
||||
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
|
||||
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
|
||||
>
|
||||
<div
|
||||
v-if="isContactSidebarOpen"
|
||||
id="contact-sidebar-content"
|
||||
class="order-2 w-[85%] sm:w-[50%] bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak overflow-y-auto py-6 shadow-lg"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
+1
-5
@@ -47,11 +47,7 @@ defineExpose({ dialogRef });
|
||||
ref="dialogRef"
|
||||
type="alert"
|
||||
:title="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.TITLE')"
|
||||
:description="
|
||||
t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION', {
|
||||
contactName: props.selectedContact.name,
|
||||
})
|
||||
"
|
||||
:description="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION')"
|
||||
:confirm-button-label="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.CONFIRM')"
|
||||
@confirm="handleDialogConfirm"
|
||||
/>
|
||||
|
||||
@@ -218,10 +218,13 @@ const resetForm = () => {
|
||||
Object.assign(state, defaultState);
|
||||
};
|
||||
|
||||
watch(() => props.contactData, prepareStateBasedOnProps, {
|
||||
immediate: true,
|
||||
deep: true,
|
||||
});
|
||||
watch(
|
||||
() => props.contactData?.id,
|
||||
id => {
|
||||
if (id) prepareStateBasedOnProps();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Expose state to parent component for avatar upload
|
||||
defineExpose({
|
||||
|
||||
+67
-85
@@ -7,42 +7,16 @@ import ContactMoreActions from './components/ContactMoreActions.vue';
|
||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||
|
||||
defineProps({
|
||||
showSearch: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
searchValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
headerTitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
activeSort: {
|
||||
type: String,
|
||||
default: 'last_activity_at',
|
||||
},
|
||||
activeOrdering: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isSegmentsView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hasActiveFilters: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isLabelView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showSearch: { type: Boolean, default: true },
|
||||
searchValue: { type: String, default: '' },
|
||||
headerTitle: { type: String, required: true },
|
||||
buttonLabel: { type: String, default: '' },
|
||||
activeSort: { type: String, default: 'last_activity_at' },
|
||||
activeOrdering: { type: String, default: '' },
|
||||
isSegmentsView: { type: Boolean, default: false },
|
||||
hasActiveFilters: { type: Boolean, default: false },
|
||||
isLabelView: { type: Boolean, default: false },
|
||||
isActiveView: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -60,13 +34,13 @@ const emit = defineEmits([
|
||||
<template>
|
||||
<header class="sticky top-0 z-10">
|
||||
<div
|
||||
class="flex items-center justify-between w-full h-20 px-6 gap-2 mx-auto max-w-[60rem]"
|
||||
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
|
||||
>
|
||||
<span class="text-xl font-medium truncate text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
<div class="flex items-center flex-shrink-0 gap-4">
|
||||
<div v-if="showSearch" class="flex items-center gap-2">
|
||||
<div class="flex items-center flex-col sm:flex-row flex-shrink-0 gap-4">
|
||||
<div v-if="showSearch" class="flex items-center gap-2 w-full">
|
||||
<Input
|
||||
:model-value="searchValue"
|
||||
type="search"
|
||||
@@ -74,6 +48,7 @@ const emit = defineEmits([
|
||||
:custom-input-class="[
|
||||
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
|
||||
]"
|
||||
class="w-full"
|
||||
@input="emit('search', $event.target.value)"
|
||||
>
|
||||
<template #prefix>
|
||||
@@ -84,59 +59,66 @@ const emit = defineEmits([
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="!isLabelView" class="relative">
|
||||
<div class="flex items-center flex-shrink-0 gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="!isLabelView && !isActiveView" class="relative">
|
||||
<Button
|
||||
id="toggleContactsFilterButton"
|
||||
:icon="
|
||||
isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter'
|
||||
"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="relative w-8"
|
||||
variant="ghost"
|
||||
@click="emit('filter')"
|
||||
>
|
||||
<div
|
||||
v-if="hasActiveFilters && !isSegmentsView"
|
||||
class="absolute top-0 right-0 w-2 h-2 rounded-full bg-n-brand"
|
||||
/>
|
||||
</Button>
|
||||
<slot name="filter" />
|
||||
</div>
|
||||
<Button
|
||||
id="toggleContactsFilterButton"
|
||||
:icon="
|
||||
isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter'
|
||||
v-if="
|
||||
hasActiveFilters &&
|
||||
!isSegmentsView &&
|
||||
!isLabelView &&
|
||||
!isActiveView
|
||||
"
|
||||
icon="i-lucide-save"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="relative w-8"
|
||||
variant="ghost"
|
||||
@click="emit('filter')"
|
||||
>
|
||||
<div
|
||||
v-if="hasActiveFilters && !isSegmentsView"
|
||||
class="absolute top-0 right-0 w-2 h-2 rounded-full bg-n-brand"
|
||||
/>
|
||||
</Button>
|
||||
<slot name="filter" />
|
||||
@click="emit('createSegment')"
|
||||
/>
|
||||
<Button
|
||||
v-if="isSegmentsView && !isLabelView && !isActiveView"
|
||||
icon="i-lucide-trash"
|
||||
color="slate"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="emit('deleteSegment')"
|
||||
/>
|
||||
<ContactSortMenu
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
@update:sort="emit('update:sort', $event)"
|
||||
/>
|
||||
<ContactMoreActions
|
||||
@add="emit('add')"
|
||||
@import="emit('import')"
|
||||
@export="emit('export')"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
v-if="hasActiveFilters && !isSegmentsView && !isLabelView"
|
||||
icon="i-lucide-save"
|
||||
color="slate"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="emit('createSegment')"
|
||||
/>
|
||||
<Button
|
||||
v-if="isSegmentsView && !isLabelView"
|
||||
icon="i-lucide-trash"
|
||||
color="slate"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="emit('deleteSegment')"
|
||||
/>
|
||||
<ContactSortMenu
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
@update:sort="emit('update:sort', $event)"
|
||||
/>
|
||||
<ContactMoreActions
|
||||
@add="emit('add')"
|
||||
@import="emit('import')"
|
||||
@export="emit('export')"
|
||||
/>
|
||||
<div class="w-px h-4 bg-n-strong" />
|
||||
<ComposeConversation>
|
||||
<template #trigger="{ toggle }">
|
||||
<Button :label="buttonLabel" size="sm" @click="toggle" />
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
</div>
|
||||
<div class="w-px h-4 bg-n-strong" />
|
||||
<ComposeConversation>
|
||||
<template #trigger="{ toggle }">
|
||||
<Button :label="buttonLabel" size="sm" @click="toggle" />
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
+18
-11
@@ -36,6 +36,7 @@ const props = defineProps({
|
||||
activeSegment: { type: Object, default: null },
|
||||
hasAppliedFilters: { type: Boolean, default: false },
|
||||
isLabelView: { type: Boolean, default: false },
|
||||
isActiveView: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -61,6 +62,7 @@ const segmentsQuery = ref({});
|
||||
|
||||
const appliedFilters = useMapGetter('contacts/getAppliedContactFiltersV4');
|
||||
const contactAttributes = useMapGetter('attributes/getContactAttributes');
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
const hasActiveSegments = computed(
|
||||
() => props.activeSegment && props.segmentsId !== 0
|
||||
);
|
||||
@@ -214,6 +216,7 @@ const setParamsForEditSegmentModal = () => {
|
||||
countries,
|
||||
filterTypes: contactFilterItems,
|
||||
allCustomAttributes: useSnakeCase(contactAttributes.value),
|
||||
labels: labels.value || [],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -277,6 +280,7 @@ defineExpose({
|
||||
:header-title="headerTitle"
|
||||
:is-segments-view="hasActiveSegments"
|
||||
:is-label-view="isLabelView"
|
||||
:is-active-view="isActiveView"
|
||||
:has-active-filters="hasAppliedFilters"
|
||||
:button-label="t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')"
|
||||
@search="emit('search', $event)"
|
||||
@@ -289,17 +293,20 @@ defineExpose({
|
||||
@delete-segment="openDeleteSegmentDialog"
|
||||
>
|
||||
<template #filter>
|
||||
<ContactsFilter
|
||||
v-if="showFiltersModal"
|
||||
v-model="appliedFilter"
|
||||
:segment-name="activeSegmentName"
|
||||
:is-segment-view="hasActiveSegments"
|
||||
class="absolute mt-1 ltr:right-0 rtl:left-0 top-full"
|
||||
@apply-filter="onApplyFilter"
|
||||
@update-segment="onUpdateSegment"
|
||||
@close="closeAdvanceFiltersModal"
|
||||
@clear-filters="clearFilters"
|
||||
/>
|
||||
<div
|
||||
class="absolute mt-1 ltr:-right-52 rtl:-left-52 sm:ltr:right-0 sm:rtl:left-0 top-full"
|
||||
>
|
||||
<ContactsFilter
|
||||
v-if="showFiltersModal"
|
||||
v-model="appliedFilter"
|
||||
:segment-name="activeSegmentName"
|
||||
:is-segment-view="hasActiveSegments"
|
||||
@apply-filter="onApplyFilter"
|
||||
@update-segment="onUpdateSegment"
|
||||
@close="closeAdvanceFiltersModal"
|
||||
@clear-filters="clearFilters"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ContactsHeader>
|
||||
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@ const handleOrderChange = value => {
|
||||
<div
|
||||
v-if="isMenuOpen"
|
||||
v-on-clickaway="() => (isMenuOpen = false)"
|
||||
class="absolute top-full mt-1 ltr:right-0 rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
|
||||
@@ -6,7 +6,7 @@ import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/Contact
|
||||
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
|
||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
searchValue: { type: String, default: '' },
|
||||
headerTitle: { type: String, default: '' },
|
||||
showPaginationFooter: { type: Boolean, default: true },
|
||||
@@ -37,10 +37,23 @@ const isNotSegmentView = computed(() => {
|
||||
return route.name !== 'contacts_dashboard_segments_index';
|
||||
});
|
||||
|
||||
const isActiveView = computed(() => {
|
||||
return route.name === 'contacts_dashboard_active';
|
||||
});
|
||||
|
||||
const isLabelView = computed(
|
||||
() => route.name === 'contacts_dashboard_labels_index'
|
||||
);
|
||||
|
||||
const showActiveFiltersPreview = computed(() => {
|
||||
return (
|
||||
(props.hasAppliedFilters || !isNotSegmentView.value) &&
|
||||
!props.isFetchingList &&
|
||||
!isLabelView.value &&
|
||||
!isActiveView.value
|
||||
);
|
||||
});
|
||||
|
||||
const updateCurrentPage = page => {
|
||||
emit('update:currentPage', page);
|
||||
};
|
||||
@@ -57,7 +70,7 @@ const openFilter = () => {
|
||||
<div class="flex flex-col w-full h-full transition-all duration-300">
|
||||
<ContactListHeaderWrapper
|
||||
ref="contactListHeaderWrapper"
|
||||
:show-search="isNotSegmentView"
|
||||
:show-search="isNotSegmentView && !isActiveView"
|
||||
:search-value="searchValue"
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
@@ -66,6 +79,7 @@ const openFilter = () => {
|
||||
:segments-id="segmentsId"
|
||||
:has-applied-filters="hasAppliedFilters"
|
||||
:is-label-view="isLabelView"
|
||||
:is-active-view="isActiveView"
|
||||
@update:sort="emit('update:sort', $event)"
|
||||
@search="emit('search', $event)"
|
||||
@apply-filter="emit('applyFilter', $event)"
|
||||
@@ -74,11 +88,7 @@ const openFilter = () => {
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<div class="w-full mx-auto max-w-[60rem]">
|
||||
<ContactsActiveFiltersPreview
|
||||
v-if="
|
||||
(hasAppliedFilters || !isNotSegmentView) &&
|
||||
!isFetchingList &&
|
||||
!isLabelView
|
||||
"
|
||||
v-if="showActiveFiltersPreview"
|
||||
:active-segment="activeSegment"
|
||||
@clear-filters="emit('clearFilters')"
|
||||
@open-filter="openFilter"
|
||||
@@ -86,10 +96,7 @@ const openFilter = () => {
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</main>
|
||||
<footer
|
||||
v-if="showPaginationFooter"
|
||||
class="sticky bottom-0 z-10 px-4 pb-4"
|
||||
>
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
|
||||
<PaginationFooter
|
||||
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||
:current-page="currentPage"
|
||||
|
||||
@@ -130,7 +130,7 @@ const onMergeContacts = async () => {
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
||||
@click="resetState"
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -87,8 +87,10 @@ useKeyboardEvents(keyboardEvents);
|
||||
<ContactNoteItem
|
||||
v-for="note in notes"
|
||||
:key="note.id"
|
||||
class="mx-6 py-4"
|
||||
:note="note"
|
||||
:written-by="getWrittenBy(note)"
|
||||
allow-delete
|
||||
@delete="onDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+56
-10
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import { useTemplateRef, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -14,39 +16,63 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
allowDelete: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
collapsible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['delete']);
|
||||
|
||||
const noteContentRef = useTemplateRef('noteContentRef');
|
||||
const needsCollapse = ref(false);
|
||||
const [isExpanded, toggleExpanded] = useToggle();
|
||||
const { t } = useI18n();
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
const handleDelete = () => {
|
||||
emit('delete', props.note.id);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (props.collapsible) {
|
||||
// Check if content height exceeds approximately 4 lines
|
||||
// Assuming line height is ~1.625 and font size is ~14px
|
||||
const threshold = 14 * 1.625 * 4; // ~84px
|
||||
needsCollapse.value = noteContentRef.value?.clientHeight > threshold;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-2 py-2 mx-6 border-b border-n-strong group/note"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-1.5 py-2.5 min-w-0">
|
||||
<div class="flex flex-col gap-2 border-b border-n-strong group/note">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<Avatar
|
||||
:name="note?.user?.name || 'Bot'"
|
||||
:src="note?.user?.thumbnail || '/assets/images/chatwoot_bot.png'"
|
||||
:src="
|
||||
note?.user?.name
|
||||
? note?.user?.thumbnail
|
||||
: '/assets/images/chatwoot_bot.png'
|
||||
"
|
||||
:size="16"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="min-w-0 truncate">
|
||||
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
|
||||
<span class="font-medium">{{ writtenBy }}</span>
|
||||
<span class="font-medium text-n-slate-12">{{ writtenBy }}</span>
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
|
||||
<span class="font-medium">{{ dynamicTime(note.createdAt) }}</span>
|
||||
<span class="font-medium text-n-slate-12">
|
||||
{{ dynamicTime(note.createdAt) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="allowDelete"
|
||||
variant="faded"
|
||||
color="ruby"
|
||||
size="xs"
|
||||
@@ -56,8 +82,28 @@ const handleDelete = () => {
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
ref="noteContentRef"
|
||||
v-dompurify-html="formatMessage(note.content || '')"
|
||||
class="mb-0 prose-sm prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
|
||||
class="mb-0 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
|
||||
:class="{
|
||||
'line-clamp-4': collapsible && !isExpanded && needsCollapse,
|
||||
}"
|
||||
/>
|
||||
<p v-if="collapsible && needsCollapse">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="blue"
|
||||
size="xs"
|
||||
:icon="isExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
@click="() => toggleExpanded()"
|
||||
>
|
||||
<template v-if="isExpanded">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.COLLAPSE') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.EXPAND') }}
|
||||
</template>
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+3
-3
@@ -4,8 +4,8 @@ export default [
|
||||
city: 'Los Angeles',
|
||||
country: 'United States',
|
||||
description:
|
||||
"I'm Candice, a developer focusing on building web solutions. Currently, I’m working as a Product Developer at Chatwoot.",
|
||||
companyName: 'Chatwoot',
|
||||
"I'm Candice, a developer focusing on building web solutions. Currently, I’m working as a Product Developer at Lumora.",
|
||||
companyName: 'Lumora',
|
||||
countryCode: 'US',
|
||||
socialProfiles: {
|
||||
github: 'candice-dev',
|
||||
@@ -16,7 +16,7 @@ export default [
|
||||
},
|
||||
},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'candice.matherson@chatwoot.com',
|
||||
email: 'candice.matherson@lumora.com',
|
||||
id: 22,
|
||||
name: 'Candice Matherson',
|
||||
phoneNumber: '+14155552671',
|
||||
|
||||
@@ -71,6 +71,7 @@ const toggleExpanded = id => {
|
||||
:thumbnail="contact.thumbnail"
|
||||
:phone-number="contact.phoneNumber"
|
||||
:additional-attributes="contact.additionalAttributes"
|
||||
:availability-status="contact.availabilityStatus"
|
||||
:is-expanded="expandedCardId === contact.id"
|
||||
:is-updating="isUpdating"
|
||||
@toggle="toggleExpanded(contact.id)"
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup>
|
||||
import { computed, ref, useAttrs } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
phone: { type: String, default: '' },
|
||||
label: { type: String, default: '' },
|
||||
icon: { type: [String, Object, Function], default: '' },
|
||||
size: { type: String, default: 'sm' },
|
||||
tooltipLabel: { type: String, default: '' },
|
||||
});
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
const attrs = useAttrs();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const voiceInboxes = computed(() =>
|
||||
(inboxesList.value || []).filter(
|
||||
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
||||
)
|
||||
);
|
||||
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
||||
|
||||
// Unified behavior: hide when no phone
|
||||
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const onClick = () => {
|
||||
if (voiceInboxes.value.length > 1) {
|
||||
dialogRef.value?.open();
|
||||
return;
|
||||
}
|
||||
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
||||
};
|
||||
|
||||
const onPickInbox = () => {
|
||||
// Placeholder until actual call wiring happens
|
||||
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="contents">
|
||||
<Button
|
||||
v-if="shouldRender"
|
||||
v-tooltip.top-end="tooltipLabel || null"
|
||||
v-bind="attrs"
|
||||
:label="label"
|
||||
:icon="icon"
|
||||
:size="size"
|
||||
@click="onClick"
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
v-if="shouldRender && voiceInboxes.length > 1"
|
||||
ref="dialogRef"
|
||||
:title="$t('CONTACT_PANEL.VOICE_INBOX_PICKER.TITLE')"
|
||||
show-cancel-button
|
||||
:show-confirm-button="false"
|
||||
width="md"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button
|
||||
v-for="inbox in voiceInboxes"
|
||||
:key="inbox.id"
|
||||
type="button"
|
||||
class="flex items-center justify-between w-full px-4 py-2 text-left rounded-lg hover:bg-n-alpha-2"
|
||||
@click="onPickInbox(inbox)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="i-ri-phone-fill text-n-slate-10" />
|
||||
<span class="text-sm text-n-slate-12">{{ inbox.name }}</span>
|
||||
</div>
|
||||
<span v-if="inbox.phone_number" class="text-xs text-n-slate-10">
|
||||
{{ inbox.phone_number }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</span>
|
||||
</template>
|
||||
+1
@@ -47,6 +47,7 @@ const unreadMessagesCount = computed(() => {
|
||||
</p>
|
||||
<div class="flex items-center flex-shrink-0 gap-2 pb-2">
|
||||
<Avatar
|
||||
v-if="assignee.name"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="20"
|
||||
|
||||
+1
@@ -96,6 +96,7 @@ defineExpose({
|
||||
/>
|
||||
</div>
|
||||
<Avatar
|
||||
v-if="assignee.name"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="20"
|
||||
|
||||
+2
-2
@@ -48,8 +48,8 @@ const inbox = computed(() => props.stateInbox);
|
||||
const inboxName = computed(() => inbox.value?.name);
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
const { phoneNumber, channelType } = inbox.value;
|
||||
return getInboxIconByType(channelType, phoneNumber);
|
||||
const { channelType, medium } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium);
|
||||
});
|
||||
|
||||
const lastActivityAt = computed(() => {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { computed } from 'vue';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
|
||||
const { updateUISettings } = useUISettings();
|
||||
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const showCopilotTab = computed(() =>
|
||||
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
|
||||
);
|
||||
|
||||
const { uiSettings } = useUISettings();
|
||||
const isContactSidebarOpen = computed(
|
||||
() => uiSettings.value.is_contact_sidebar_open
|
||||
);
|
||||
const isCopilotPanelOpen = computed(
|
||||
() => uiSettings.value.is_copilot_panel_open
|
||||
);
|
||||
|
||||
const toggleConversationSidebarToggle = () => {
|
||||
updateUISettings({
|
||||
is_contact_sidebar_open: !isContactSidebarOpen.value,
|
||||
is_copilot_panel_open: false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleConversationSidebarToggle = () => {
|
||||
updateUISettings({
|
||||
is_contact_sidebar_open: true,
|
||||
is_copilot_panel_open: false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopilotSidebarToggle = () => {
|
||||
updateUISettings({
|
||||
is_contact_sidebar_open: false,
|
||||
is_copilot_panel_open: true,
|
||||
});
|
||||
};
|
||||
|
||||
const keyboardEvents = {
|
||||
'Alt+KeyO': {
|
||||
action: toggleConversationSidebarToggle,
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
class="!rounded-full"
|
||||
:class="{
|
||||
'bg-n-alpha-2': isContactSidebarOpen,
|
||||
}"
|
||||
icon="i-ph-user-bold"
|
||||
@click="handleConversationSidebarToggle"
|
||||
/>
|
||||
<Button
|
||||
v-if="showCopilotTab"
|
||||
v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')"
|
||||
ghost
|
||||
slate
|
||||
class="!rounded-full"
|
||||
:class="{
|
||||
'bg-n-alpha-2 !text-n-iris-9': isCopilotPanelOpen,
|
||||
}"
|
||||
sm
|
||||
icon="i-woot-captain"
|
||||
@click="handleCopilotSidebarToggle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,38 +4,14 @@ import { computed, ref, watch, useSlots } from 'vue';
|
||||
import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
focusOnMount: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
maxLength: {
|
||||
type: Number,
|
||||
default: 200,
|
||||
},
|
||||
showCharacterCount: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
modelValue: { type: String, default: '' },
|
||||
label: { type: String, default: '' },
|
||||
placeholder: { type: String, default: '' },
|
||||
focusOnMount: { type: Boolean, default: false },
|
||||
maxLength: { type: Number, default: 200 },
|
||||
showCharacterCount: { type: Boolean, default: true },
|
||||
disabled: { type: Boolean, default: false },
|
||||
message: { type: String, default: '' },
|
||||
messageType: {
|
||||
type: String,
|
||||
default: 'info',
|
||||
@@ -43,6 +19,8 @@ const props = defineProps({
|
||||
},
|
||||
enableVariables: { type: Boolean, default: false },
|
||||
enableCannedResponses: { type: Boolean, default: true },
|
||||
enabledMenuOptions: { type: Array, default: () => [] },
|
||||
enableCaptainTools: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
@@ -58,7 +36,7 @@ const messageClass = computed(() => {
|
||||
case 'error':
|
||||
return 'text-n-ruby-9 dark:text-n-ruby-9';
|
||||
case 'success':
|
||||
return 'text-green-500 dark:text-green-400';
|
||||
return 'text-n-teal-10 dark:text-n-teal-10';
|
||||
default:
|
||||
return 'text-n-slate-11 dark:text-n-slate-11';
|
||||
}
|
||||
@@ -120,6 +98,8 @@ watch(
|
||||
:disabled="disabled"
|
||||
:enable-variables="enableVariables"
|
||||
:enable-canned-responses="enableCannedResponses"
|
||||
:enabled-menu-options="enabledMenuOptions"
|
||||
:enable-captain-tools="enableCaptainTools"
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
|
||||
@@ -35,12 +35,12 @@ defineProps({
|
||||
<div class="flex flex-col items-center justify-center gap-6">
|
||||
<div class="flex flex-col items-center justify-center gap-3">
|
||||
<h2
|
||||
class="text-3xl font-medium text-center text-slate-900 dark:text-white font-interDisplay"
|
||||
class="text-3xl font-medium text-center text-n-slate-12 font-interDisplay"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
class="max-w-xl text-base text-center text-slate-600 dark:text-slate-300 font-interDisplay tracking-[0.3px]"
|
||||
class="max-w-xl text-base text-center text-n-slate-11 font-interDisplay tracking-[0.3px]"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ const category = {
|
||||
<div
|
||||
v-for="(article, index) in articles"
|
||||
:key="index"
|
||||
class="px-20 py-4 bg-white dark:bg-slate-900"
|
||||
class="px-20 py-4 bg-n-background"
|
||||
>
|
||||
<ArticleCard
|
||||
:id="article.id"
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ const categories = [
|
||||
<div
|
||||
v-for="(category, index) in categories"
|
||||
:key="index"
|
||||
class="px-20 py-4 bg-white dark:bg-slate-900"
|
||||
class="px-20 py-4 bg-n-background"
|
||||
>
|
||||
<CategoryCard
|
||||
:id="category.id"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user