# --
# OTOBO is a web-based ticketing system for service organisations.
# --
# Copyright (C) 2001-2020 OTRS AG, https://otrs.com/
# Copyright (C) 2019-2026 Rother OSS GmbH, https://otobo.io/
# --
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later version.
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
# --
package Kernel::Modules::AgentTicketZoom;
use strict;
use warnings;
use utf8;
our $ObjectManagerDisabled = 1;
# core modules
use List::Util qw(any);
use POSIX qw(ceil);
# CPAN modules
# OTOBO modules
use Kernel::System::VariableCheck qw(:all);
use Kernel::Language qw(Translatable);
sub new {
my ( $Type, %Param ) = @_;
# allocate new hash for object
my $Self = bless {%Param}, $Type;
# set debug
$Self->{Debug} = 0;
# get needed objects
my $ParamObject = $Kernel::OM->Get('Kernel::System::Web::Request');
my $ConfigObject = $Kernel::OM->Get('Kernel::Config');
my $UserObject = $Kernel::OM->Get('Kernel::System::User');
my $LayoutObject = $Kernel::OM->Get('Kernel::Output::HTML::Layout');
$Self->{ArticleID} = $ParamObject->GetParam( Param => 'ArticleID' );
$Self->{ArticleView} = $ParamObject->GetParam( Param => 'ArticleView' );
$Self->{ZoomExpand} = $ParamObject->GetParam( Param => 'ZoomExpand' );
$Self->{ZoomExpandSort} = $ParamObject->GetParam( Param => 'ZoomExpandSort' );
$Self->{ZoomTimeline} = $ParamObject->GetParam( Param => 'ZoomTimeline' );
my %UserPreferences = $UserObject->GetPreferences(
UserID => $Self->{UserID},
);
# save last used view type in preferences
if ( !$Self->{Subaction} ) {
if (
!defined $Self->{ArticleView}
&& !defined $Self->{ZoomExpand}
&& !defined $Self->{ZoomTimeline}
)
{
$Self->{ZoomExpand} = $ConfigObject->Get('Ticket::Frontend::AgentZoomExpand');
if ( $UserPreferences{UserLastUsedZoomViewType} ) {
if ( $UserPreferences{UserLastUsedZoomViewType} eq 'Expand' ) {
$Self->{ZoomExpand} = 1;
}
elsif ( $UserPreferences{UserLastUsedZoomViewType} eq 'Collapse' ) {
$Self->{ZoomExpand} = 0;
}
elsif ( $UserPreferences{UserLastUsedZoomViewType} eq 'Timeline' ) {
$Self->{ZoomTimeline} = 1;
}
}
}
elsif (
defined $Self->{ArticleView}
|| defined $Self->{ZoomExpand}
|| defined $Self->{ZoomTimeline}
)
{
my $LastUsedZoomViewType = '';
if ( defined $Self->{ArticleView} ) {
$LastUsedZoomViewType = $Self->{ArticleView};
if ( $Self->{ArticleView} eq 'Expand' ) {
$Self->{ZoomExpand} = 1;
}
elsif ( $Self->{ArticleView} eq 'Collapse' ) {
$Self->{ZoomExpand} = 0;
}
elsif ( $Self->{ArticleView} eq 'Timeline' ) {
$Self->{ZoomTimeline} = 1;
}
else {
$LastUsedZoomViewType = $ConfigObject->Get('Ticket::Frontend::AgentZoomExpand')
? 'Expand'
: 'Collapse';
}
}
elsif ( defined $Self->{ZoomExpand} && $Self->{ZoomExpand} == 1 ) {
$LastUsedZoomViewType = 'Expand';
}
elsif ( defined $Self->{ZoomExpand} && $Self->{ZoomExpand} == 0 ) {
$LastUsedZoomViewType = 'Collapse';
}
elsif ( defined $Self->{ZoomTimeline} && $Self->{ZoomTimeline} == 1 ) {
$LastUsedZoomViewType = 'Timeline';
}
$UserObject->SetPreferences(
UserID => $Self->{UserID},
Key => 'UserLastUsedZoomViewType',
Value => $LastUsedZoomViewType,
);
}
}
if ( !$ConfigObject->Get('TimelineViewEnabled') ) {
$Self->{ZoomTimeline} = 0;
}
# whether the message "To open links in the following article, ..." is shown
$Self->{DoNotShowBrowserLinkMessage} //= $UserPreferences{UserAgentDoNotShowBrowserLinkMessage};
$Self->{ZoomExpandSort} //= $ConfigObject->Get('Ticket::Frontend::ZoomExpandSort');
$Self->{ArticleFilterActive} = $ConfigObject->Get('Ticket::Frontend::TicketArticleFilter');
# define if rich text should be used
$Self->{RichText} = $ConfigObject->Get('Ticket::Frontend::ZoomRichTextForce')
|| $LayoutObject->{BrowserRichText}
|| 0;
# Always exclude plain text attachment, but exclude HTML body only if rich text is enabled.
$Self->{ExcludeAttachments} = {
ExcludePlainText => 1,
ExcludeHTMLBody => $Self->{RichText},
ExcludeInline => $Self->{RichText},
};
# get ticket object
my $TicketObject = $Kernel::OM->Get('Kernel::System::Ticket');
# ticket id lookup
if ( !$Self->{TicketID} && $ParamObject->GetParam( Param => 'TicketNumber' ) ) {
$Self->{TicketID} = $TicketObject->TicketIDLookup(
TicketNumber => $ParamObject->GetParam( Param => 'TicketNumber' ),
UserID => $Self->{UserID},
);
}
# get display settings for AgentTicketZoom, e.g. which widgets are shown
$Self->{DisplaySettings} = $ConfigObject->Get("Ticket::Frontend::AgentTicketZoom");
# this is a mapping of history types which is being used
# for the timeline view and its event type filter
$Self->{HistoryTypeMapping} = {
TicketLinkDelete => Translatable('Link Deleted'),
Lock => Translatable('Ticket Locked'),
SetPendingTime => Translatable('Pending Time Set'),
TicketDynamicFieldUpdate => Translatable('Dynamic Field Updated'),
EmailAgentInternal => Translatable('Outgoing Email (internal)'),
NewTicket => Translatable('Ticket Created'),
TypeUpdate => Translatable('Type Updated'),
EscalationUpdateTimeStart => Translatable('Escalation Update Time In Effect'),
EscalationUpdateTimeStop => Translatable('Escalation Update Time Stopped'),
EscalationFirstResponseTimeStop => Translatable('Escalation First Response Time Stopped'),
CustomerUpdate => Translatable('Customer Updated'),
ChatInternal => Translatable('Internal Chat'),
SendAutoFollowUp => Translatable('Automatic Follow-Up Sent'),
AddNote => Translatable('Note Added'),
AddNoteCustomer => Translatable('Note Added (Customer)'),
AddSMS => Translatable('SMS Added'),
AddSMSCustomer => Translatable('SMS Added (Customer)'),
StateUpdate => Translatable('State Updated'),
SendAnswer => Translatable('Outgoing Answer'),
ServiceUpdate => Translatable('Service Updated'),
TicketLinkAdd => Translatable('Link Added'),
EmailCustomer => Translatable('Incoming Customer Email'),
WebRequestCustomer => Translatable('Incoming Web Request'),
PriorityUpdate => Translatable('Priority Updated'),
Unlock => Translatable('Ticket Unlocked'),
EmailAgent => Translatable('Outgoing Email'),
TitleUpdate => Translatable('Title Updated'),
OwnerUpdate => Translatable('New Owner'),
Merged => Translatable('Ticket Merged'),
PhoneCallAgent => Translatable('Outgoing Phone Call'),
Forward => Translatable('Forwarded Message'),
Unsubscribe => Translatable('Removed User Subscription'),
TimeAccounting => Translatable('Time Accounted'),
PhoneCallCustomer => Translatable('Incoming Phone Call'),
SystemRequest => Translatable('System Request.'),
FollowUp => Translatable('Incoming Follow-Up'),
SendAutoReply => Translatable('Automatic Reply Sent'),
SendAutoReject => Translatable('Automatic Reject Sent'),
ResponsibleUpdate => Translatable('New Responsible'),
EscalationSolutionTimeStart => Translatable('Escalation Solution Time In Effect'),
EscalationSolutionTimeStop => Translatable('Escalation Solution Time Stopped'),
EscalationResponseTimeStart => Translatable('Escalation Response Time In Effect'),
EscalationResponseTimeStop => Translatable('Escalation Response Time Stopped'),
SLAUpdate => Translatable('SLA Updated'),
ChatExternal => Translatable('External Chat'),
Move => Translatable('Queue Changed'),
SendAgentNotification => Translatable('Notification Was Sent'),
};
# Add custom files to the zoom's frontend module registration on the fly
# to avoid conflicts with other modules.
if (
defined $ConfigObject->Get('TimelineViewEnabled')
&& $ConfigObject->Get('TimelineViewEnabled') == 1
)
{
$ConfigObject->Set(
Key => 'Loader::Module::AgentTicketZoom###003-DynamicField',
Value => {
JavaScript => [
'Core.Agent.TicketZoom.TimelineView.js',
],
},
);
}
my $ArticleShowStatus = $Kernel::OM->Get('Kernel::System::Ticket::ArticleFeatures')->ShowDeletedArticles(
TicketID => $Self->{TicketID},
UserID => $Self->{UserID},
GetStatus => 1
);
$Self->{ShowDeletedArticles} = $ArticleShowStatus ? 1 : 0;
$Self->{ArticleStorage} = $ConfigObject->Get('Ticket::Article::Backend::MIMEBase::ArticleStorage');
return $Self;
}
sub Run {
my ( $Self, %Param ) = @_;
# get layout object
my $LayoutObject = $Kernel::OM->Get('Kernel::Output::HTML::Layout');
# check needed stuff
if ( !$Self->{TicketID} ) {
return $LayoutObject->ErrorScreen(
Message => Translatable('No TicketID is given!'),
Comment => Translatable('Please contact the administrator.'),
);
}
# get needed objects
my $TicketObject = $Kernel::OM->Get('Kernel::System::Ticket');
my $ArticleObject = $Kernel::OM->Get('Kernel::System::Ticket::Article');
# check permissions
my $Access = $TicketObject->TicketPermission(
Type => 'ro',
TicketID => $Self->{TicketID},
UserID => $Self->{UserID}
);
# error screen, don't show ticket
return $LayoutObject->NoPermission(
Message => Translatable(
"This ticket does not exist, or you don't have permissions to access it in its current state."
),
WithHeader => $Self->{Subaction} && $Self->{Subaction} eq 'ArticleUpdate' ? 'no' : 'yes',
) if !$Access;
# get ticket attributes
my %Ticket = $TicketObject->TicketGet(
TicketID => $Self->{TicketID},
DynamicFields => 1,
);
# get ACL restrictions
my %PossibleActions;
my $Counter = 0;
# get config object
my $ConfigObject = $Kernel::OM->Get('Kernel::Config');
# get all registered Actions
if ( ref $ConfigObject->Get('Frontend::Module') eq 'HASH' ) {
my %Actions = %{ $ConfigObject->Get('Frontend::Module') };
# only use those Actions that stats with Agent
%PossibleActions = map { ++$Counter => $_ }
grep { substr( $_, 0, length 'Agent' ) eq 'Agent' }
sort keys %Actions;
}
my $ACL = $TicketObject->TicketAcl(
Data => \%PossibleActions,
Action => $Self->{Action},
TicketID => $Self->{TicketID},
ReturnType => 'Action',
ReturnSubType => '-',
UserID => $Self->{UserID},
);
my %AclAction = %PossibleActions;
if ($ACL) {
%AclAction = $TicketObject->TicketAclActionData();
}
# check if ACL restrictions exist
my %AclActionLookup = reverse %AclAction;
# show error screen if ACL prohibits this action
if ( !$AclActionLookup{ $Self->{Action} } ) {
return $LayoutObject->NoPermission( WithHeader => 'yes' );
}
# send parameter TicketID to JS
$LayoutObject->AddJSData(
Key => 'TicketID',
Value => $Self->{TicketID},
);
# mark shown ticket as seen
if ( $Self->{Subaction} eq 'TicketMarkAsSeen' ) {
my $Success = 1;
# always show archived tickets as seen
if ( $Ticket{ArchiveFlag} ne 'y' ) {
$Success = $Self->_TicketItemSeen( TicketID => $Self->{TicketID} );
}
return $LayoutObject->Attachment(
ContentType => 'text/html',
Content => $Success,
Type => 'inline',
NoCache => 1,
);
}
if ( $Self->{Subaction} eq 'MarkAsImportant' ) {
# Owner and Responsible can mark articles as important or remove mark
if (
$Self->{UserID} == $Ticket{OwnerID}
|| (
$ConfigObject->Get('Ticket::Responsible')
&& $Self->{UserID} == $Ticket{ResponsibleID}
)
)
{
# Always use user id 1 because other users also have to see the important flag
my %ArticleFlag = $ArticleObject->ArticleFlagGet(
TicketID => $Self->{TicketID},
ArticleID => $Self->{ArticleID},
UserID => 1,
);
my $ArticleIsImportant = $ArticleFlag{Important};
if ($ArticleIsImportant) {
# Always use user id 1 because other users also have to see the important flag
$ArticleObject->ArticleFlagDelete(
TicketID => $Self->{TicketID},
ArticleID => $Self->{ArticleID},
Key => 'Important',
UserID => 1,
);
}
else {
# Always use user id 1 because other users also have to see the important flag
$ArticleObject->ArticleFlagSet(
TicketID => $Self->{TicketID},
ArticleID => $Self->{ArticleID},
Key => 'Important',
Value => 1,
UserID => 1,
);
}
}
return $LayoutObject->Redirect(
OP => "Action=AgentTicketZoom;TicketID=$Self->{TicketID};ArticleID=$Self->{ArticleID}",
);
}
# get required objects
my $ParamObject = $Kernel::OM->Get('Kernel::System::Web::Request');
my $MainObject = $Kernel::OM->Get('Kernel::System::Main');
if ( $Self->{Subaction} eq 'FormDraftDelete' ) {
my %Response;
my $FormDraftID = $ParamObject->GetParam( Param => 'FormDraftID' ) || '';
if ($FormDraftID) {
$Response{Success} = $Kernel::OM->Get('Kernel::System::FormDraft')->FormDraftDelete(
FormDraftID => $FormDraftID,
UserID => $Self->{UserID},
);
}
else {
$Response{Error} = $LayoutObject->{LanguageObject}->Translate("Missing FormDraftID!");
}
# build JSON output
my $JSON = $LayoutObject->JSONEncode(
Data => \%Response,
);
# send JSON response
return $LayoutObject->Attachment(
ContentType => 'application/json',
Content => $JSON,
Type => 'inline',
NoCache => 1,
);
}
if ( $Self->{Subaction} eq 'LoadWidget' ) {
my $ElementID = $ParamObject->GetParam( Param => 'ElementID' );
my $Config;
WIDGET:
for my $Key ( sort keys %{ $Self->{DisplaySettings}->{Widgets} // {} } ) {
if ( $ElementID eq 'Async_' . $LayoutObject->LinkEncode($Key) ) {
$Config = $Self->{DisplaySettings}->{Widgets}->{$Key};
last WIDGET;
}
}
if ($Config) {
my $Success = eval { $MainObject->Require( $Config->{Module} ) };
if ( !$Success ) {
$Kernel::OM->Get('Kernel::System::Log')->Log(
Priority => 'error',
Message => "Cannot load $Config->{Module}: $@",
);
return $LayoutObject->Attachment(
ContentType => 'text/html',
Content => '',
Type => 'inline',
NoCache => 1,
);
}
my $Module = eval { $Config->{Module}->new( %{$Self} ) };
if ( !$Module ) {
$Kernel::OM->Get('Kernel::System::Log')->Log(
Priority => 'error',
Message => "new() of Widget module $Config->{Module} not successful!",
);
return $LayoutObject->Attachment(
ContentType => 'text/html',
Content => '',
Type => 'inline',
NoCache => 1,
);
}
my $WidgetOutput = $Module->Run(
Ticket => \%Ticket,
AclAction => \%AclAction,
Config => $Config,
);
return $LayoutObject->Attachment(
ContentType => 'text/html',
Content => $WidgetOutput->{Output} // ' ',
Type => 'inline',
NoCache => 1,
Charset => 'utf-8',
);
}
else {
$Kernel::OM->Get('Kernel::System::Log')->Log(
Priority => 'error',
Message => "Cannot locate module for ElementID $ElementID",
);
return $LayoutObject->Attachment(
ContentType => 'text/html',
Content => '',
Type => 'inline',
NoCache => 1,
);
}
}
# mark shown article as seen
if ( $Self->{Subaction} eq 'MarkAsSeen' ) {
my $Success = 1;
my $IsArticleDeleted = $Kernel::OM->Get('Kernel::System::Ticket::ArticleFeatures')->IsArticleDeleted(
ArticleID => $Self->{ArticleID},
);
if ($IsArticleDeleted) {
$Success = 2;
}
else {
# always show archived tickets as seen
if ( $Ticket{ArchiveFlag} ne 'y' ) {
$Success = $Self->_ArticleItemSeen(
TicketID => $Self->{TicketID},
ArticleID => $Self->{ArticleID}
);
}
}
return $LayoutObject->Attachment(
ContentType => 'text/html',
Content => $Success,
Type => 'inline',
NoCache => 1,
);
}
# article update
elsif ( $Self->{Subaction} eq 'ArticleUpdate' ) {
my $Count = $ParamObject->GetParam( Param => 'Count' );
my $ArticleBackendObject = $ArticleObject->BackendForArticle(
TicketID => $Self->{TicketID},
ArticleID => $Self->{ArticleID},
ShowDeletedArticles => $Self->{ShowDeletedArticles}
);
my %Article = $ArticleBackendObject->ArticleGet(
TicketID => $Self->{TicketID},
ArticleID => $Self->{ArticleID},
RealNames => 1,
DynamicFields => 0,
);
$Article{Count} = $Count;
# Get attachment index (excluding body attachments).
my %AtmIndex = $ArticleBackendObject->ArticleAttachmentIndex(
ArticleID => $Self->{ArticleID},
%{ $Self->{ExcludeAttachments} },
);
$Article{Atms} = \%AtmIndex;
# fetch all std. templates
my %StandardTemplates = $Kernel::OM->Get('Kernel::System::Queue')->QueueStandardTemplateMemberList(
QueueID => $Ticket{QueueID},
TemplateTypes => 1,
Valid => 1,
);
my $ArticleWidgetsHTML = $Self->_ArticleItem(
Ticket => \%Ticket,
Article => \%Article,
AclAction => \%AclAction,
StandardResponses => $StandardTemplates{Answer},
StandardForwards => $StandardTemplates{Forward},
Type => 'OnLoad',
);
# send data to JS
$LayoutObject->AddJSData(
Key => 'ArticleIDs',
Value => [ $Self->{ArticleID} ],
);
$LayoutObject->AddJSData(
Key => 'MenuItems',
Value => $Self->{MenuItems},
);
my $Content = $LayoutObject->Output(
TemplateFile => 'AgentTicketZoom',
Data => {
%Ticket,
%Article,
%AclAction,
ArticleWidgetsHTML => $ArticleWidgetsHTML
},
AJAX => 1,
);
if ( !$Content ) {
$LayoutObject->FatalError(
Message =>
$LayoutObject->{LanguageObject}->Translate( 'Can\'t get for ArticleID %s!', $Self->{ArticleID} ),
);
}
return $LayoutObject->Attachment(
ContentType => 'text/html',
Charset => $LayoutObject->{UserCharset},
Content => $Content,
Type => 'inline',
NoCache => 1,
);
}
# get needed objects
my $UserObject = $Kernel::OM->Get('Kernel::System::User');
my $SessionObject = $Kernel::OM->Get('Kernel::System::AuthSession');
# write article filter settings to session
if ( $Self->{Subaction} eq 'ArticleFilterSet' ) {
# get params
my $TicketID = $ParamObject->GetParam( Param => 'TicketID' );
my $SaveDefaults = $ParamObject->GetParam( Param => 'SaveDefaults' );
my @CommunicationChannelFilterIDs = $ParamObject->GetArray( Param => 'CommunicationChannelFilter' );
my $CustomerVisibility = $ParamObject->GetParam( Param => 'CustomerVisibilityFilter' );
my @ArticleSenderTypeFilterIDs = $ParamObject->GetArray( Param => 'ArticleSenderTypeFilter' );
# build session string
my $SessionString = '';
if (@CommunicationChannelFilterIDs) {
$SessionString .= 'CommunicationChannelFilter<';
$SessionString .= join ',', @CommunicationChannelFilterIDs;
$SessionString .= '>';
}
if ( defined $CustomerVisibility && $CustomerVisibility != 2 ) {
$SessionString .= "CustomerVisibilityFilter<$CustomerVisibility>";
}
if (@ArticleSenderTypeFilterIDs) {
$SessionString .= 'ArticleSenderTypeFilter<';
$SessionString .= join ',', @ArticleSenderTypeFilterIDs;
$SessionString .= '>';
}
# write the session
# save default filter settings to user preferences
if ($SaveDefaults) {
$UserObject->SetPreferences(
UserID => $Self->{UserID},
Key => 'ArticleFilterDefault',
Value => $SessionString,
);
$SessionObject->UpdateSessionID(
SessionID => $Self->{SessionID},
Key => 'ArticleFilterDefault',
Value => $SessionString,
);
}
# turn off filter explicitly for this ticket
if ( $SessionString eq '' ) {
$SessionString = 'off';
}
# update the session
my $Update = $SessionObject->UpdateSessionID(
SessionID => $Self->{SessionID},
Key => "ArticleFilter$TicketID",
Value => $SessionString,
);
# build JSON output
my $JSON = '';
if ($Update) {
$JSON = $LayoutObject->JSONEncode(
Data => {
Message => Translatable('Article filter settings were saved.'),
},
);
}
# send JSON response
return $LayoutObject->Attachment(
ContentType => 'application/json',
Content => $JSON,
Type => 'inline',
NoCache => 1,
);
}
# write article filter settings to session
if ( $Self->{Subaction} eq 'EvenTypeFilterSet' ) {
# get params
my $TicketID = $ParamObject->GetParam( Param => 'TicketID' );
my $SaveDefaults = $ParamObject->GetParam( Param => 'SaveDefaults' );
my @EventTypeFilterIDs = $ParamObject->GetArray( Param => 'EventTypeFilter' );
# build session string
my $SessionString = '';
if (@EventTypeFilterIDs) {
$SessionString .= 'EventTypeFilter<';
$SessionString .= join ',', @EventTypeFilterIDs;
$SessionString .= '>';
}
# write the session
# save default filter settings to user preferences
if ($SaveDefaults) {
$UserObject->SetPreferences(
UserID => $Self->{UserID},
Key => 'EventTypeFilterDefault',
Value => $SessionString,
);
$SessionObject->UpdateSessionID(
SessionID => $Self->{SessionID},
Key => 'EventTypeFilterDefault',
Value => $SessionString,
);
}
# turn off filter explicitly for this ticket
if ( $SessionString eq '' ) {
$SessionString = 'off';
}
# update the session
my $Update = $SessionObject->UpdateSessionID(
SessionID => $Self->{SessionID},
Key => "EventTypeFilter$TicketID",
Value => $SessionString,
);
# build JSON output
my $JSON = '';
if ($Update) {
$JSON = $LayoutObject->JSONEncode(
Data => {
Message => Translatable('Event type filter settings were saved.'),
},
);
}
# send JSON response
return $LayoutObject->Attachment(
ContentType => 'application/json',
Content => $JSON,
Type => 'inline',
NoCache => 1,
);
}
# article filter is activated in sysconfig
if ( $Self->{ArticleFilterActive} ) {
# get article filter settings from session string
my $ArticleFilterSessionString = $Self->{Session}{ 'ArticleFilter' . $Self->{TicketID} };
# set article filter for this ticket from user preferences
if ( !$ArticleFilterSessionString ) {
$ArticleFilterSessionString = $Self->{Session}{ArticleFilterDefault};
}
# do not use defaults for this ticket if filter was explicitly turned off
elsif ( $ArticleFilterSessionString eq 'off' ) {
$ArticleFilterSessionString = '';
}
# extract CommunicationChannels
if (
$ArticleFilterSessionString
&& $ArticleFilterSessionString =~ m{ CommunicationChannelFilter < ( [^<>]+ ) > }xms
)
{
my @IDs = split /,/, $1;
$Self->{ArticleFilter}->{CommunicationChannelID} = \@IDs;
}
# extract CustomerVisibility
if (
$ArticleFilterSessionString
&& $ArticleFilterSessionString =~ m{ CustomerVisibilityFilter < ( [^<>]+ ) > }xms
)
{
$Self->{ArticleFilter}->{CustomerVisibility} = $1;
}
# extract ArticleSenderTypeIDs
if (
$ArticleFilterSessionString
&& $ArticleFilterSessionString =~ m{ ArticleSenderTypeFilter < ( [^<>]+ ) > }xms
)
{
my @IDs = split /,/, $1;
$Self->{ArticleFilter}->{ArticleSenderTypeID} = \@IDs;
}
# get event type filter settings from session string
my $EventTypeFilterSessionString = $Self->{ 'EventTypeFilter' . $Self->{TicketID} };
# set article filter for this ticket from user preferences
if ( !$EventTypeFilterSessionString ) {
$EventTypeFilterSessionString = $Self->{EventTypeFilterDefault};
}
# do not use defaults for this ticket if filter was explicitly turned off
elsif ( $EventTypeFilterSessionString eq 'off' ) {
$EventTypeFilterSessionString = '';
}
# Set article filter with value if it exists.
if (
$EventTypeFilterSessionString
&& $EventTypeFilterSessionString =~ m{ EventTypeFilter < ( [^<>]+ ) > }xms
)
{
my @IDs = split /,/, $1;
$Self->{EventTypeFilter}->{EventTypeID} = \@IDs;
}
}
# generate output
return join '',
$LayoutObject->Header(
Value => $Ticket{TicketNumber},
TicketID => $Ticket{TicketID},
),
$LayoutObject->NavigationBar,
$Self->MaskAgentZoom(
Ticket => \%Ticket,
AclAction => \%AclAction
),
$LayoutObject->Footer;
}
sub MaskAgentZoom {
my ( $Self, %Param ) = @_;
my %Ticket = %{ $Param{Ticket} };
my %AclAction = %{ $Param{AclAction} };
# get needed objects
my $TicketObject = $Kernel::OM->Get('Kernel::System::Ticket');
my $ArticleObject = $Kernel::OM->Get('Kernel::System::Ticket::Article');
# Create a list of article sender types for lookup
my %ArticleSenderTypeList = $ArticleObject->ArticleSenderTypeList();
# else show normal ticket zoom view
# fetch all move queues
my %MoveQueues = $TicketObject->MoveList(
TicketID => $Ticket{TicketID},
UserID => $Self->{UserID},
Action => 'AgentTicketMove',
Type => 'move_into',
);
# fetch all std. templates
my %StandardTemplates = $Kernel::OM->Get('Kernel::System::Queue')->QueueStandardTemplateMemberList(
QueueID => $Ticket{QueueID},
TemplateTypes => 1,
);
# get config object
my $ConfigObject = $Kernel::OM->Get('Kernel::Config');
# generate shown articles
my $Limit = $ConfigObject->Get('Ticket::Frontend::MaxArticlesPerPage');
my $Order = $Self->{ZoomExpandSort} eq 'reverse' ? 'DESC' : 'ASC';
my $Page;
# get param object
my $ParamObject = $Kernel::OM->Get('Kernel::System::Web::Request');
# get article page
my $ArticlePage = $ParamObject->GetParam( Param => 'ArticlePage' );
my $IsVisibleForCustomer;
if ( defined $Self->{ArticleFilter}->{CustomerVisibility} ) {
$IsVisibleForCustomer = $Self->{ArticleFilter}->{CustomerVisibility};
}
# Get all articles.
my @ArticleBoxAll = $ArticleObject->ArticleList(
TicketID => $Self->{TicketID},
IsVisibleForCustomer => $IsVisibleForCustomer,
ShowDeletedArticles => $Self->{ShowDeletedArticles}
);
if ( IsArrayRefWithData( $Self->{ArticleFilter}->{CommunicationChannelID} ) ) {
my %Filter = map { $_ => 1 } @{ $Self->{ArticleFilter}->{CommunicationChannelID} };
@ArticleBoxAll = grep { $Filter{ $_->{CommunicationChannelID} } } @ArticleBoxAll;
}
if ( IsArrayRefWithData( $Self->{ArticleFilter}->{ArticleSenderTypeID} ) ) {
my %Filter = map { $_ => 1 } @{ $Self->{ArticleFilter}->{ArticleSenderTypeID} };
@ArticleBoxAll = grep { $Filter{ $_->{SenderTypeID} } } @ArticleBoxAll;
}
if ( $Order eq 'DESC' ) {
@ArticleBoxAll = reverse @ArticleBoxAll;
}
my %ArticleFlags = $ArticleObject->ArticleFlagsOfTicketGet(
TicketID => $Ticket{TicketID},
UserID => $Self->{UserID},
);
my $ArticleID;
if ( $Self->{ArticleID} ) {
my @ArticleIDs = map { $_->{ArticleID} } @ArticleBoxAll;
my %ArticleIndex;
@ArticleIndex{@ArticleIDs} = ( 0 .. $#ArticleIDs );
my $Index = $ArticleIndex{ $Self->{ArticleID} };
$Index //= 0;
$Page = int( $Index / $Limit ) + 1;
}
elsif ($ArticlePage) {
$Page = $ArticlePage;
}
else {
# Find latest not seen article.
ARTICLE:
for my $Article (@ArticleBoxAll) {
# Ignore system sender type.
if (
$ConfigObject->Get('Ticket::NewArticleIgnoreSystemSender')
&& $ArticleSenderTypeList{ $Article->{SenderTypeID} } eq 'system'
)
{
next ARTICLE;
}
next ARTICLE if $ArticleFlags{ $Article->{ArticleID} }->{Seen};
$ArticleID = $Article->{ArticleID};
my @ArticleIDs = map { $_->{ArticleID} } @ArticleBoxAll;
my %ArticleIndex;
@ArticleIndex{@ArticleIDs} = ( 0 .. $#ArticleIDs );
my $Index = $ArticleIndex{$ArticleID};
$Page = int( $Index / $Limit ) + 1;
last ARTICLE;
}
if ( !$ArticleID ) {
$Page = 1;
}
}
# We need to find out whether pagination is actually necessary.
# The easiest way would be count the articles, but that would slow
# down the most common case (fewer articles than $Limit in the ticket).
# So instead we use the following trick:
# 1) if the $Page > 1, we need pagination
# 2) if not, request $Limit + 1 articles. If $Limit + 1 are actually
# returned, pagination is necessary
my $NeedPagination;
my @ArticleBox = $Self->_ArticleBoxGet(
Page => $Page,
ArticleBoxAll => \@ArticleBoxAll,
Limit => $Limit,
);
if ( !@ArticleBox && $Page > 1 ) {
# If the page argument is past the actual number of pages.
# This can happen when a new article filter was added.
# Try to get results for the 1st page.
@ArticleBox = $Self->_ArticleBoxGet(
Page => 1,
ArticleBoxAll => \@ArticleBoxAll,
Limit => $Limit,
);
}
if ( @ArticleBox > $Limit ) {
pop @ArticleBox;
$NeedPagination = 1;
}
elsif ( $Page == 1 && scalar @ArticleBoxAll <= $Limit ) {
$NeedPagination = 0;
}
else {
$NeedPagination = 1;
}
$Page ||= 1;
my $Pages;
if ($NeedPagination) {
$Pages = ceil( scalar @ArticleBoxAll / $Limit );
}
my $ArticleIDFound = 0;
ARTICLE:
for my $Article (@ArticleBox) {
next ARTICLE if !$Self->{ArticleID};
next ARTICLE if !$Article->{ArticleID};
next ARTICLE if $Self->{ArticleID} ne $Article->{ArticleID};
$ArticleIDFound = 1;
}
# get selected or last customer article
if ($ArticleIDFound) {
$ArticleID = $Self->{ArticleID};
}
else {
if ( !$ArticleID ) {
if (@ArticleBox) {
# set first listed article as fallback
$ArticleID = $ArticleBox[0]->{ArticleID};
# set last customer article as selected article replacing last set
ARTICLETMP:
for my $ArticleTmp (@ArticleBox) {
if ( $ArticleSenderTypeList{ $ArticleTmp->{SenderTypeID} } eq 'customer' ) {
$ArticleID = $ArticleTmp->{ArticleID};
last ARTICLETMP if $Self->{ZoomExpandSort} eq 'reverse';
}
}
}
}
}
# check if expand view is usable (only for less then 400 article)
# if you have more articles is going to be slow and not usable
my $ArticleMaxLimit = $ConfigObject->Get('Ticket::Frontend::MaxArticlesZoomExpand')
// 400;
if ( $Self->{ZoomExpand} && $#ArticleBox > $ArticleMaxLimit ) {
$Self->{ZoomExpand} = 0;
}
# get shown article(s)
my @ArticleBoxShown;
if ( !$Self->{ZoomExpand} ) {
ARTICLEBOX:
for my $ArticleTmp (@ArticleBox) {
if ( $ArticleID eq $ArticleTmp->{ArticleID} ) {
push @ArticleBoxShown, $ArticleTmp;
last ARTICLEBOX;
}
}
}
else {
@ArticleBoxShown = @ArticleBox;
}
# get layout object
my $LayoutObject = $Kernel::OM->Get('Kernel::Output::HTML::Layout');
# age design
$Ticket{Age} = $LayoutObject->CustomerAge(
Age => $Ticket{Age},
Space => ' '
);
my $MainObject = $Kernel::OM->Get('Kernel::System::Main');
my %Widgets;
my %AsyncWidgetActions;
WIDGET:
for my $Key ( sort keys %{ $Self->{DisplaySettings}->{Widgets} // {} } ) {
my $Config = $Self->{DisplaySettings}->{Widgets}->{$Key};
if ( $Config->{Async} ) {
if ( !$Config->{Location} ) {
$Kernel::OM->Get('Kernel::System::Log')->Log(
Priority => 'error',
Message =>
"The configuration for $Config->{Module} must contain a Location, because it is marked as Async.",
);
next WIDGET;
}
my $ElementID = 'Async_' . $LayoutObject->LinkEncode($Key);
push @{ $Widgets{ $Config->{Location} } }, {
Async => 1,
Rank => $Config->{Rank} || $Key,
%Ticket,
ElementID => $ElementID,
};
$AsyncWidgetActions{$ElementID} = "Action=$Self->{Action};Subaction=LoadWidget;"
. "TicketID=$Self->{TicketID};ElementID=$ElementID";
next WIDGET;
}
my $Success = eval { $MainObject->Require( $Config->{Module} ) };
next WIDGET unless $Success;
my $Module = eval { $Config->{Module}->new(%$Self) };
if ( !$Module ) {
$Kernel::OM->Get('Kernel::System::Log')->Log(
Priority => 'error',
Message => "new() of Widget module $Config->{Module} not successful!",
);
next WIDGET;
}
my $WidgetOutput = $Module->Run(
Ticket => \%Ticket,
AclAction => \%AclAction,
Config => $Config,
);
next WIDGET unless $WidgetOutput;
$WidgetOutput->{Rank} //= $Key;
my $Location = $WidgetOutput->{Location} || $Config->{Location};
push @{ $Widgets{$Location} }, $WidgetOutput;
}
for my $Location ( sort keys %Widgets ) {
$Param{ $Location . 'Widgets' } = [
sort { $a->{Rank} cmp $b->{Rank} } @{ $Widgets{$Location} }
];
}
$LayoutObject->AddJSData(
Key => 'AsyncWidgetActions',
Value => \%AsyncWidgetActions,
);
# set display options
$Param{Hook} = $ConfigObject->Get('Ticket::Hook') || 'Ticket#';
# only show article tree if articles are present,
# or if a filter is set (so that the user has the option to
# disable the filter)
if ( @ArticleBox || $Self->{ArticleFilter} ) {
my $Pagination;
if ($NeedPagination) {
$Pagination = {
Pages => $Pages,
CurrentPage => $Page,
TicketID => $Ticket{TicketID},
};
}
# show article tree
$Param{ArticleTree} = $Self->_ArticleTree(
Ticket => \%Ticket,
ArticleFlags => \%ArticleFlags,
ArticleID => $ArticleID,
ArticleMaxLimit => $ArticleMaxLimit,
ArticleBox => \@ArticleBox,
Pagination => $Pagination,
Page => $Page,
ArticleCount => scalar @ArticleBox,
AclAction => \%AclAction,
StandardResponses => $StandardTemplates{Answer},
StandardForwards => $StandardTemplates{Forward},
);
}
# show articles items
if ( !$Self->{ZoomTimeline} ) {
my @ArticleIDs;
$Param{ArticleItems} = '';
my $ArticleWidgetsHTML = '';
ARTICLE:
for my $ArticleTmp (@ArticleBoxShown) {
my %Article = %$ArticleTmp;
$ArticleWidgetsHTML .= $Self->_ArticleItem(
Ticket => \%Ticket,
Article => \%Article,
AclAction => \%AclAction,
StandardResponses => $StandardTemplates{Answer},
StandardForwards => $StandardTemplates{Forward},
ActualArticleID => $ArticleID,
Type => 'Static',
);
push @ArticleIDs, $ArticleTmp->{ArticleID};
}
# send data to JS
$LayoutObject->AddJSData(
Key => 'ArticleIDs',
Value => \@ArticleIDs,
);
$LayoutObject->AddJSData(
Key => 'MenuItems',
Value => $Self->{MenuItems},
);
$Param{ArticleItems} .= $LayoutObject->Output(
TemplateFile => 'AgentTicketZoom',
Data => {
%Ticket,
%AclAction,
ArticleWidgetsHTML => $ArticleWidgetsHTML,
},
);
}
# always show archived tickets as seen
if ( $Self->{ZoomExpand} && $Ticket{ArchiveFlag} ne 'y' ) {
# send data to JS
$LayoutObject->AddJSData(
Key => 'TicketItemMarkAsSeen',
Value => 1,
);
}
# number of articles
$Param{ArticleCount} = scalar @ArticleBox;
$LayoutObject->Block(
Name => 'Header',
Data => { %Param, %Ticket, %AclAction },
);
my %ActionLookup;
my $UserObject = $Kernel::OM->Get('Kernel::System::User');
# run ticket menu modules
if ( ref $ConfigObject->Get('Ticket::Frontend::MenuModule') eq 'HASH' ) {
my %Menus = %{ $ConfigObject->Get('Ticket::Frontend::MenuModule') };
my %MenuClusters;
my %ZoomMenuItems;
MENU:
for my $Menu ( sort keys %Menus ) {
# load module
if ( !$Kernel::OM->Get('Kernel::System::Main')->Require( $Menus{$Menu}->{Module} ) ) {
return $LayoutObject->FatalError();
}
my $Object = $Menus{$Menu}->{Module}->new(
%{$Self},
TicketID => $Self->{TicketID},
);
# run module
my $Item = $Object->Run(
%Param,
Ticket => \%Ticket,
ACL => \%AclAction,
Config => $Menus{$Menu},
);
next MENU if !$Item;
if ( $Menus{$Menu}->{PopupType} ) {
$Item->{Class} = "AsPopup PopupType_$Menus{$Menu}->{PopupType}";
}
if ( $Menus{$Menu}->{Action} ) {
$ActionLookup{ $Menus{$Menu}->{Action} } = {
Link => $Item->{Link},
Class => $Item->{Class},
LinkParam => $Item->{LinkParam},
Description => $Item->{Description},
Name => $Item->{Name},
TranslatedName => $Kernel::OM->Get('Kernel::Language')->Translate( $Item->{Name} ),
};
}
if ( !$Menus{$Menu}->{ClusterName} ) {
$ZoomMenuItems{$Menu} = $Item;
}
else {
# check the configured priority for this item. The lowest ClusterPriority
# within the same cluster wins.
my $Priority = $MenuClusters{ $Menus{$Menu}->{ClusterName} }->{Priority} || 0;
$Menus{$Menu}->{ClusterPriority} ||= 0;
if ( !$Priority || $Priority !~ /^\d{3}$/ || $Priority > $Menus{$Menu}->{ClusterPriority} ) {
$Priority = $Menus{$Menu}->{ClusterPriority};
}
$MenuClusters{ $Menus{$Menu}->{ClusterName} }->{Priority} = $Priority;
$MenuClusters{ $Menus{$Menu}->{ClusterName} }->{Items}->{$Menu} = $Item;
}
}
for my $Cluster ( sort keys %MenuClusters ) {
$ZoomMenuItems{ $MenuClusters{$Cluster}->{Priority} . $Cluster } = {
Name => $Cluster,
Type => 'Cluster',
Link => '#',
Class => 'ClusterLink',
Items => $MenuClusters{$Cluster}->{Items},
};
}
# display all items
for my $Item ( sort keys %ZoomMenuItems ) {
if ( $ZoomMenuItems{$Item}->{ExternalLink} && $ZoomMenuItems{$Item}->{ExternalLink} == 1 ) {
$LayoutObject->Block(
Name => 'TicketMenuExternalLink',
Data => $ZoomMenuItems{$Item},
);
}
else {
$LayoutObject->Block(
Name => 'TicketMenu',
Data => $ZoomMenuItems{$Item},
);
}
if ( $ZoomMenuItems{$Item}->{Type} eq 'Cluster' ) {
$LayoutObject->Block(
Name => 'TicketMenuSubContainer',
Data => {
Name => $ZoomMenuItems{$Item}->{Name},
},
);
# create a map of priorities versus subitems based on the ClusterPriority of each subitem
# the settings key order prevails between subitems with equal ClusterPriority
# priorities are always appended a sequential suffix of two digits (800-01, 800-02, 800-03, etc.)
# this enforces distinct map keys even among subitems with the same ClusterPriority
my %PrioritySuffixes;
my %MapPriorityToSubItem;
for my $SubItem ( sort keys %{ $ZoomMenuItems{$Item}->{Items} } ) {
my $ItemPriority = $ZoomMenuItems{$Item}->{Items}->{$SubItem}->{ClusterPriority};
if ( !$PrioritySuffixes{$ItemPriority} ) {
$PrioritySuffixes{$ItemPriority} = 1;
}
else {
$PrioritySuffixes{$ItemPriority}++;
}
# enforce the two digits suffix, since 10 or more subitems are possible
if ( $PrioritySuffixes{$ItemPriority} =~ /^\d$/ ) {
$PrioritySuffixes{$ItemPriority} = "0" . $PrioritySuffixes{$ItemPriority};
}
my $PriorityKey = $ItemPriority . "-" . $PrioritySuffixes{$ItemPriority};
$MapPriorityToSubItem{$PriorityKey} = $SubItem;
}
# render subitems according to the priorities map
for my $PriorityKey ( sort keys %MapPriorityToSubItem ) {
my $SubItem = $MapPriorityToSubItem{$PriorityKey};
$LayoutObject->Block(
Name => 'TicketMenuSubContainerItem',
Data => $ZoomMenuItems{$Item}->{Items}->{$SubItem},
);
}
}
}
}
# get MoveQueuesStrg
if ( $ConfigObject->Get('Ticket::Frontend::MoveType') =~ /^form$/i ) {
$MoveQueues{0} = '- ' . $LayoutObject->{LanguageObject}->Translate('Move') . ' -';
$Param{MoveQueuesStrg} = $LayoutObject->AgentQueueListOption(
Name => 'DestQueueID',
TreeView => $ConfigObject->Get('Ticket::Frontend::ListType') eq 'tree' ? 1 : 0,
Data => \%MoveQueues,
Class => 'Modernize Small',
CurrentQueueID => $Ticket{QueueID},
);
}
my %AclActionLookup = reverse %AclAction;
if (
$ConfigObject->Get('Frontend::Module')->{AgentTicketMove}
&& ( $AclActionLookup{AgentTicketMove} )
)
{
my $Access = $TicketObject->TicketPermission(
Type => 'move',
TicketID => $Ticket{TicketID},
UserID => $Self->{UserID},
LogNo => 1,
);
$Param{TicketID} = $Ticket{TicketID};
if ($Access) {
if ( $ConfigObject->Get('Ticket::Frontend::MoveType') =~ /^form$/i ) {
$LayoutObject->Block(
Name => 'MoveLink',
Data => { %Param, %AclAction },
);
}
else {
$LayoutObject->Block(
Name => 'MoveForm',
Data => { %Param, %AclAction },
);
}
$ActionLookup{AgentTicketMove} = {
Link => 'Action=AgentTicketMove;TicketID=[% Data.TicketID | uri %]',
Class => 'AsPopup PopupType_TicketAction',
LinkParam => '',
Description => Translatable('Change Queue'),
Name => Translatable('Queue'),
TranslatedName => $Kernel::OM->Get('Kernel::Language')->Translate('Queue'),
};
}
}
# Check if AgentTicketCompose and AgentTicketForward are allowed as action (for display of FormDrafts).
my %ActionConfig = (
AgentTicketCompose => {
Link => 'Action=AgentTicketCompose;TicketID=[% Data.TicketID | uri %]',
Class => 'AsPopup PopupType_TicketAction',
LinkParam => '',
Description => Translatable('Reply'),
Name => Translatable('Reply'),
TranslatedName => $Kernel::OM->Get('Kernel::Language')->Translate('Reply'),
},
AgentTicketForward => {
Link => 'Action=AgentTicketForward;TicketID=[% Data.TicketID | uri %]',
Class => 'AsPopup PopupType_TicketAction',
LinkParam => '',
Description => Translatable('Forward article via mail'),
Name => Translatable('Forward'),
TranslatedName => $Kernel::OM->Get('Kernel::Language')->Translate('Forward'),
},
);
ACTION:
for my $Action (qw(AgentTicketCompose AgentTicketForward)) {
next ACTION if !$ConfigObject->Get('Frontend::Module')->{$Action};
next ACTION if !$AclActionLookup{$Action};
my $Config = $ConfigObject->Get( 'Ticket::Frontend::' . $Action );
if ( $Config->{Permission} ) {
next ACTION if !$TicketObject->TicketPermission(
Type => $Config->{Permission},
TicketID => $Ticket{TicketID},
UserID => $Self->{UserID},
LogNo => 1,
);
}
$ActionLookup{$Action} = $ActionConfig{$Action};
}
# Get and show available FormDrafts.
my %ShownFormDraftEntries;
my $FormDraftList = $Kernel::OM->Get('Kernel::System::FormDraft')->FormDraftListGet(
ObjectType => 'Ticket',
ObjectID => $Self->{TicketID},
UserID => $Self->{UserID},
);
if ( IsArrayRefWithData($FormDraftList) ) {
FormDraft:
for my $FormDraft ( @{$FormDraftList} ) {
next FormDraft if !$ActionLookup{ $FormDraft->{Action} };
push @{ $ShownFormDraftEntries{ $FormDraft->{Action} } }, $FormDraft;
}
}
if (%ShownFormDraftEntries) {
my $LastArticle;
if ( $Order eq 'DESC' ) {
$LastArticle = $ArticleBoxAll[0];
}
else {
$LastArticle = $ArticleBoxAll[-1];
}
my $LastArticleSystemTime;
if ( $LastArticle->{CreateTime} ) {
my $LastArticleSystemTimeObject = $Kernel::OM->Create(
'Kernel::System::DateTime',
ObjectParams => {
String => $LastArticle->{CreateTime},
},
);
$LastArticleSystemTime = $LastArticleSystemTimeObject->ToEpoch();
}
my @FormDrafts;
for my $Action (
sort {
$ActionLookup{$a}->{TranslatedName}
cmp
$ActionLookup{$b}->{TranslatedName}
} keys %ShownFormDraftEntries
)
{
my $ActionData = $ActionLookup{$Action};
SHOWNFormDraftACTIONENTRY:
for my $ShownFormDraftActionEntry (
sort {
$a->{Title}
cmp
$b->{Title}
||
$a->{FormDraftID}
<=>
$b->{FormDraftID}
} @{ $ShownFormDraftEntries{$Action} }
)
{
$ShownFormDraftActionEntry->{CreatedByUser} = $UserObject->UserName(
UserID => $ShownFormDraftActionEntry->{CreateBy},
);
$ShownFormDraftActionEntry->{ChangedByUser} = $UserObject->UserName(
UserID => $ShownFormDraftActionEntry->{ChangeBy},
);
$ShownFormDraftActionEntry = {
%{$ShownFormDraftActionEntry},
%{$ActionData},
};
push @FormDrafts, $ShownFormDraftActionEntry;
}
}
$LayoutObject->Block(
Name => 'FormDraftTable',
Data => {
FormDrafts => \@FormDrafts,
TicketID => $Self->{TicketID},
},
);
}
# show created by if different then User ID 1
if ( $Ticket{CreateBy} > 1 ) {
# get user object
my $UserObject = $Kernel::OM->Get('Kernel::System::User');
$Ticket{CreatedByUser} = $UserObject->UserName( UserID => $Ticket{CreateBy} );
$LayoutObject->Block(
Name => 'CreatedBy',
Data => {%Ticket},
);
}
# show no articles block if ticket does not contain articles
if ( !@ArticleBox && !$Self->{ZoomTimeline} ) {
$LayoutObject->Block(
Name => 'HintNoArticles',
);
}
# check if ticket is normal or process ticket
my $IsProcessTicket = $TicketObject->TicketCheckForProcessType(
'TicketID' => $Self->{TicketID}
);
# collect data for overview widget
my %WidgetData;
if ( $IsProcessTicket || $Self->{DisplaySettings}{DynamicFieldWidgetDisplay} ) {
%WidgetData = $IsProcessTicket
? (
WidgetDisplay => $Self->{DisplaySettings}{ProcessDisplay},
WidgetTitle => $Self->{DisplaySettings}{ProcessDisplay}{WidgetTitle},
WidgetDynamicFieldGroups => $Self->{DisplaySettings}{ProcessWidgetDynamicFieldGroups},
)
: (
WidgetDisplay => $Self->{DisplaySettings}{DynamicFieldWidgetDisplay},
WidgetTitle => $Self->{DisplaySettings}{DynamicFieldWidgetDisplay}{WidgetTitle},
WidgetDynamicFieldGroups => $Self->{DisplaySettings}{DynamicFieldWidgetDynamicFieldGroups},
);
$WidgetData{WidgetDynamicField} = $ConfigObject->Get("Ticket::Frontend::AgentTicketZoom")
->{ ( $IsProcessTicket ? 'ProcessWidgetDynamicField' : 'DynamicFieldWidgetDynamicField' ) } // {};
}
# decide if widget should be shown
my $ShowWidget = 0;
# always show if we have a process ticket for activity dialogs
if ($IsProcessTicket) {
$ShowWidget = 1;
}
# else show only if dynamic fields are defined and at least one of them has a value
elsif ( IsHashRefWithData( $WidgetData{WidgetDynamicField} ) ) {
DFVALUE:
for my $FieldName ( keys $WidgetData{WidgetDynamicField}->%* ) {
next DFVALUE unless $WidgetData{WidgetDynamicField}{$FieldName};
next DFVALUE unless $Ticket{"DynamicField_$FieldName"};
$ShowWidget = 1;
last DFVALUE;
}
}
# show overview widget with either dynamic field data or with process and activity dialog data
if ($ShowWidget) {
# send data to JS
$LayoutObject->AddJSData(
Key => 'OverviewWidget',
Value => 1,
);
# output the overview widget in the main screen
$LayoutObject->Block(
Name => 'OverviewWidget',
Data => {
WidgetTitle => $WidgetData{WidgetTitle},
},
);
# collect and render process data if necessary
my $ActivityName;
my $NextActivityDialogs;
my $ProcessEntityIDField;
if ($IsProcessTicket) {
# get the DF where the ProcessEntityID is stored
$ProcessEntityIDField = 'DynamicField_'
. $ConfigObject->Get("Process::DynamicFieldProcessManagementProcessID");
# get the DF where the AtivityEntityID is stored
my $ActivityEntityIDField = 'DynamicField_'
. $ConfigObject->Get("Process::DynamicFieldProcessManagementActivityID");
my $ActivityData = $Kernel::OM->Get('Kernel::System::ProcessManagement::Activity')->ActivityGet(
Interface => 'AgentInterface',
ActivityEntityID => $Ticket{$ActivityEntityIDField},
);
# get next activity dialogs
if ( $Ticket{$ActivityEntityIDField} ) {
$NextActivityDialogs = ${ActivityData}->{ActivityDialog} || {};
}
$ActivityName = $ActivityData->{Name};
}
if ($NextActivityDialogs) {
# get ActivityDialog object
my $ActivityDialogObject = $Kernel::OM->Get('Kernel::System::ProcessManagement::ActivityDialog');
# we have to check if the current user has the needed permissions to view the
# different activity dialogs, so we loop over every activity dialog and check if there
# is a permission configured. If there is a permission configured we check this
# and display/hide the activity dialog link
my %PermissionRights;
my %PermissionActivityDialogList;
ACTIVITYDIALOGPERMISSION:
for my $Index ( sort { $a <=> $b } keys %{$NextActivityDialogs} ) {
my $CurrentActivityDialogEntityID = $NextActivityDialogs->{$Index};
my $CurrentActivityDialog = $ActivityDialogObject->ActivityDialogGet(
Interface => 'AgentInterface',
ActivityDialogEntityID => $CurrentActivityDialogEntityID
);
# create an interface lookup-list
my %InterfaceLookup = map { $_ => 1 } @{ $CurrentActivityDialog->{Interface} };
next ACTIVITYDIALOGPERMISSION if !$InterfaceLookup{AgentInterface};
if ( $CurrentActivityDialog->{Permission} ) {
# performance-boost/cache
if ( !defined $PermissionRights{ $CurrentActivityDialog->{Permission} } ) {
$PermissionRights{ $CurrentActivityDialog->{Permission} } = $TicketObject->TicketPermission(
Type => $CurrentActivityDialog->{Permission},
TicketID => $Ticket{TicketID},
UserID => $Self->{UserID},
);
}
if ( !$PermissionRights{ $CurrentActivityDialog->{Permission} } ) {
next ACTIVITYDIALOGPERMISSION;
}
}
$PermissionActivityDialogList{$Index} = $CurrentActivityDialogEntityID;
}
# reduce next activity dialogs to the ones that have permissions
$NextActivityDialogs = \%PermissionActivityDialogList;
# get ACL restrictions
my $ACL = $TicketObject->TicketAcl(
Data => \%PermissionActivityDialogList,
TicketID => $Ticket{TicketID},
Action => $Self->{Action},
ReturnType => 'ActivityDialog',
ReturnSubType => '-',
UserID => $Self->{UserID},
);
if ($ACL) {
%{$NextActivityDialogs} = $TicketObject->TicketAclData();
}
$LayoutObject->Block(
Name => 'NextActivityDialogs',
Data => {
'ActivityName' => $ActivityName,
},
);
if ( IsHashRefWithData($NextActivityDialogs) ) {
for my $NextActivityDialogKey ( sort { $a <=> $b } keys %{$NextActivityDialogs} ) {
my $ActivityDialogData = $ActivityDialogObject->ActivityDialogGet(
Interface => 'AgentInterface',
ActivityDialogEntityID => $NextActivityDialogs->{$NextActivityDialogKey},
);
# check if direct submit is active for this activity dialog
my $DirectSubmit = $ActivityDialogData->{DirectSubmit};
if ( any { $ActivityDialogData->{Fields}{$_}{Display} } keys $ActivityDialogData->{Fields}->%* ) {
$DirectSubmit = 0;
}
if ($DirectSubmit) {
$LayoutObject->Block(
Name => 'ActivityDialogDirectSubmit',
Data => {
ActivityDialogEntityID
=> $NextActivityDialogs->{$NextActivityDialogKey},
Name => $ActivityDialogData->{SubmitButtonText} || $ActivityDialogData->{Name},
ProcessEntityID => $Ticket{$ProcessEntityIDField},
TicketID => $Ticket{TicketID},
},
);
}
else {
$LayoutObject->Block(
Name => 'ActivityDialog',
Data => {
ActivityDialogEntityID
=> $NextActivityDialogs->{$NextActivityDialogKey},
Name => $ActivityDialogData->{Name},
ProcessEntityID => $Ticket{$ProcessEntityIDField},
TicketID => $Ticket{TicketID},
},
);
}
}
}
else {
$LayoutObject->Block(
Name => 'NoActivityDialogs',
Data => {},
);
}
}
# get dynamic field config for frontend module
my $DynamicFieldFilter = {
%{ $ConfigObject->Get("Ticket::Frontend::AgentTicketZoom")->{DynamicField} || {} },
%{
$WidgetData{WidgetDynamicField} || {}
},
};
# get the dynamic fields for ticket object
my $DynamicField = $Kernel::OM->Get('Kernel::System::DynamicField')->DynamicFieldListGet(
Valid => 1,
ObjectType => ['Ticket'],
FieldFilter => $DynamicFieldFilter || {},
);
my $DynamicFieldBackendObject = $Kernel::OM->Get('Kernel::System::DynamicField::Backend');
# to store dynamic fields to be displayed in the overview widget
my (@FieldsWidget);
# cycle trough the activated Dynamic Fields for ticket object
DYNAMICFIELD:
for my $DynamicFieldConfig ( @{$DynamicField} ) {
next DYNAMICFIELD if !IsHashRefWithData($DynamicFieldConfig);
next DYNAMICFIELD if !defined $Ticket{ 'DynamicField_' . $DynamicFieldConfig->{Name} };
next DYNAMICFIELD if $Ticket{ 'DynamicField_' . $DynamicFieldConfig->{Name} } eq '';
# use translation here to be able to reduce the character length in the template
my $Label = $LayoutObject->{LanguageObject}->Translate( $DynamicFieldConfig->{Label} );
if ( $WidgetData{WidgetDynamicField}{ $DynamicFieldConfig->{Name} } ) {
my $ValueStrg = $DynamicFieldBackendObject->DisplayValueRender(
DynamicFieldConfig => $DynamicFieldConfig,
Value => $Ticket{ 'DynamicField_' . $DynamicFieldConfig->{Name} },
LayoutObject => $LayoutObject,
# no ValueMaxChars here, enough space available
);
push @FieldsWidget, {
$DynamicFieldConfig->{Name} => $ValueStrg->{Title},
Name => $DynamicFieldConfig->{Name},
Title => $ValueStrg->{Title},
Value => $ValueStrg->{Value},
ValueKey => $Ticket{ 'DynamicField_' . $DynamicFieldConfig->{Name} },
Label => $Label,
Link => $ValueStrg->{Link},
LinkPreview => $ValueStrg->{LinkPreview},
# Include unique parameter with dynamic field name in case of collision with others.
# Please see bug#13362 for more information.
"DynamicField_$DynamicFieldConfig->{Name}" => $ValueStrg->{Title},
};
}
}
# build dynamic field lookup hash for widget groups
my %DynamicFieldLookup = map { ( $_->{Name} => $_ ) } $DynamicField->@*;
# output dynamic fields registered for a group in the overview widget
my @FieldsInAGroup;
for my $GroupName (
sort keys %{ $WidgetData{WidgetDynamicFieldGroups} }
)
{
$LayoutObject->Block(
Name => 'OverviewWidgetDynamicFieldGroups',
);
my $GroupFieldsString = $WidgetData{WidgetDynamicFieldGroups}{$GroupName};
$GroupFieldsString =~ s{\s}{}xmsg;
my @GroupFields = split /,/, $GroupFieldsString;
if ( $#GroupFields + 1 ) {
my $ShowGroupTitle = 0;
for my $Field (@FieldsWidget) {
if ( grep { $_ eq $Field->{Name} } @GroupFields ) {
$ShowGroupTitle = 1;
$LayoutObject->Block(
Name => 'OverviewWidgetDynamicField',
Data => {
Label => $Field->{Label},
Name => $Field->{Name},
},
);
my $DFConfig = $DynamicFieldLookup{ $Field->{Name} };
# set field
if ( $DFConfig->{FieldType} eq 'Set' ) {
$LayoutObject->Block(
Name => 'SetField',
);
$LayoutObject->Block(
Name => 'DynamicFieldSetSeparator',
Data => {
Label => $Field->{Label},
},
);
my @IncludedFields = $Self->_GetIncludedFieldOrdered(
Include => $DFConfig->{Config}{Include},
);
for my $ValueIndex ( 0 .. $#{ $Ticket{ 'DynamicField_' . $Field->{Name} } } ) {
my $ValueItem = $Ticket{ 'DynamicField_' . $Field->{Name} }[$ValueIndex];
for my $IncludeField (@IncludedFields) {
my $IncludeDFConfig = $Kernel::OM->Get('Kernel::System::DynamicField')->DynamicFieldGet(
Name => $IncludeField,
);
my $ValueStrg = $DynamicFieldBackendObject->DisplayValueRender(
DynamicFieldConfig => $IncludeDFConfig,
Value => $ValueItem->{ $IncludeDFConfig->{Name} },
LayoutObject => $LayoutObject,
# no ValueMaxChars here, enough space available
);
my %IncludeField = (
$IncludeDFConfig->{Name} => $ValueStrg->{Title},
Name => $IncludeDFConfig->{Name},
Title => $ValueStrg->{Title},
Value => $ValueStrg->{Value},
ValueKey => $ValueItem->{ $IncludeDFConfig->{Name} },
Label => $IncludeDFConfig->{Label},
Link => $ValueStrg->{Link},
LinkPreview => $ValueStrg->{LinkPreview},
# Include unique parameter with dynamic field name in case of collision with others.
# Please see bug#13362 for more information.
"DynamicField_$IncludeDFConfig->{Name}" => $ValueStrg->{Title},
);
$LayoutObject->Block(
Name => 'SetDynamicField',
Data => {
Name => $IncludeField{Name},
Label => $IncludeField{Label},
}
);
$LayoutObject->Block(
Name => 'SetDynamicFieldValueOverlayTrigger',
);
if ( $IncludeDFConfig->{Link} ) {
$LayoutObject->Block(
Name => 'SetDynamicFieldLink',
Data => {
$IncludeField{Name} => $IncludeField{Title},
%Ticket,
# alias for ticket title, Title will be overwritten
TicketTitle => $Ticket{Title},
Value => $IncludeField{Value},
Title => $IncludeField{Title},
Link => $IncludeField{Link},
LinkPreview => $IncludeField{LinkPreview},
# Include unique parameter with dynamic field name in case of collision with others.
# Please see bug#13362 for more information.
"DynamicField_$IncludeField{Name}" => $IncludeField{Title},
},
);
}
else {
$LayoutObject->Block(
Name => 'SetDynamicFieldPlain',
Data => {
Value => $IncludeField{Value},
Title => $IncludeField{Title},
},
);
}
push @FieldsInAGroup, $Field->{Name};
}
if ( $ValueIndex != $#{ $Ticket{ 'DynamicField_' . $Field->{Name} } } ) {
$LayoutObject->Block(
Name => 'DynamicFieldSetValueSeparator',
);
}
}
}
# standard field
else {
$LayoutObject->Block(
Name => 'StandardField',
Data => {
Name => $Field->{Name},
Label => $Field->{Label},
},
);
$LayoutObject->Block(
Name => 'OverviewWidgetDynamicFieldValueOverlayTrigger',
);
if ( $Field->{Link} ) {
$LayoutObject->Block(
Name => 'OverviewWidgetDynamicFieldLink',
Data => {
$Field->{Name} => $Field->{Title},
%Ticket,
# alias for ticket title, Title will be overwritten
TicketTitle => $Ticket{Title},
Value => $Field->{Value},
Title => $Field->{Title},
Link => $Field->{Link},
LinkPreview => $Field->{LinkPreview},
# Include unique parameter with dynamic field name in case of collision with others.
# Please see bug#13362 for more information.
"DynamicField_$Field->{Name}" => $Field->{Title},
},
);
}
else {
$LayoutObject->Block(
Name => 'OverviewWidgetDynamicFieldPlain',
Data => {
Value => $Field->{Value},
Title => $Field->{Title},
},
);
}
push @FieldsInAGroup, $Field->{Name};
}
}
}
if ($ShowGroupTitle) {
$LayoutObject->Block(
Name => 'OverviewWidgetDynamicFieldGroupSeparator',
Data => {
Name => $GroupName,
},
);
}
}
}
# output dynamic fields not registered in a group in the overview widget
my @RemainingFieldsWidget;
for my $Field (@FieldsWidget) {
if ( !grep { $_ eq $Field->{Name} } @FieldsInAGroup ) {
push @RemainingFieldsWidget, $Field;
}
}
$LayoutObject->Block(
Name => 'OverviewWidgetDynamicFieldGroups',
);
if ( $#RemainingFieldsWidget + 1 ) {
$LayoutObject->Block(
Name => 'OverviewWidgetDynamicFieldGroupSeparator',
Data => {
Name =>
$LayoutObject->{LanguageObject}->Translate('Fields with no group'),
},
);
}
for my $Field (@RemainingFieldsWidget) {
$LayoutObject->Block(
Name => 'OverviewWidgetDynamicField',
);
my ($DFConfig) = grep { $_->{Name} eq $Field->{Name} } $DynamicField->@*;
# set field
if ( $DFConfig->{FieldType} eq 'Set' ) {
$LayoutObject->Block(
Name => 'SetField',
);
$LayoutObject->Block(
Name => 'DynamicFieldSetSeparator',
Data => {
Label => $Field->{Label},
},
);
my @IncludedFields = $Self->_GetIncludedFieldOrdered(
Include => $DFConfig->{Config}{Include},
);
for my $ValueIndex ( 0 .. $#{ $Ticket{ 'DynamicField_' . $Field->{Name} } } ) {
my $ValueItem = $Ticket{ 'DynamicField_' . $Field->{Name} }[$ValueIndex];
for my $IncludeField (@IncludedFields) {
my $IncludeDFConfig = $Kernel::OM->Get('Kernel::System::DynamicField')->DynamicFieldGet(
Name => $IncludeField,
);
my $ValueStrg = $DynamicFieldBackendObject->DisplayValueRender(
DynamicFieldConfig => $IncludeDFConfig,
Value => $ValueItem->{ $IncludeDFConfig->{Name} },
LayoutObject => $LayoutObject,
# no ValueMaxChars here, enough space available
);
my %IncludeField = (
$IncludeDFConfig->{Name} => $ValueStrg->{Title},
Name => $IncludeDFConfig->{Name},
Title => $ValueStrg->{Title},
Value => $ValueStrg->{Value},
ValueKey => $ValueItem->{ $IncludeDFConfig->{Name} },
Label => $IncludeDFConfig->{Label},
Link => $ValueStrg->{Link},
LinkPreview => $ValueStrg->{LinkPreview},
# Include unique parameter with dynamic field name in case of collision with others.
# Please see bug#13362 for more information.
"DynamicField_$IncludeDFConfig->{Name}" => $ValueStrg->{Title},
);
$LayoutObject->Block(
Name => 'SetDynamicField',
Data => {
Name => $IncludeField{Name},
Label => $IncludeField{Label},
}
);
$LayoutObject->Block(
Name => 'SetDynamicFieldValueOverlayTrigger',
);
if ( $IncludeDFConfig->{Link} ) {
$LayoutObject->Block(
Name => 'SetDynamicFieldLink',
Data => {
$IncludeField{Name} => $IncludeField{Title},
%Ticket,
# alias for ticket title, Title will be overwritten
TicketTitle => $Ticket{Title},
Value => $IncludeField{Value},
Title => $IncludeField{Title},
Link => $IncludeField{Link},
LinkPreview => $IncludeField{LinkPreview},
# Include unique parameter with dynamic field name in case of collision with others.
# Please see bug#13362 for more information.
"DynamicField_$IncludeField{Name}" => $IncludeField{Title},
},
);
}
else {
$LayoutObject->Block(
Name => 'SetDynamicFieldPlain',
Data => {
Value => $IncludeField{Value},
Title => $IncludeField{Title},
},
);
}
push @FieldsInAGroup, $Field->{Name};
}
if ( $ValueIndex != $#{ $Ticket{ 'DynamicField_' . $Field->{Name} } } ) {
$LayoutObject->Block(
Name => 'DynamicFieldSetValueSeparator',
);
}
}
}
# standard field
else {
$LayoutObject->Block(
Name => 'StandardField',
Data => {
Name => $Field->{Name},
Label => $Field->{Label},
},
);
$LayoutObject->Block(
Name => 'OverviewWidgetDynamicFieldValueOverlayTrigger',
);
if ( $Field->{Link} ) {
$LayoutObject->Block(
Name => 'OverviewWidgetDynamicFieldLink',
Data => {
$Field->{Name} => $Field->{Title},
%Ticket,
# alias for ticket title, Title will be overwritten
TicketTitle => $Ticket{Title},
Value => $Field->{Value},
Title => $Field->{Title},
Link => $Field->{Link},
LinkPreview => $Field->{LinkPreview},
# Include unique parameter with dynamic field name in case of collision with others.
# Please see bug#13362 for more information.
"DynamicField_$Field->{Name}" => $Field->{Title},
},
);
}
else {
$LayoutObject->Block(
Name => 'OverviewWidgetDynamicFieldPlain',
Data => {
Value => $Field->{Value},
Title => $Field->{Title},
},
);
}
push @FieldsInAGroup, $Field->{Name};
}
}
}
# article filter is activated in sysconfig
if ( $Self->{ArticleFilterActive} ) {
if ( $Self->{ZoomTimeline} ) {
# build event type list for filter dialog
$Param{EventTypeFilterString} = $LayoutObject->BuildSelection(
Data => $Self->{HistoryTypeMapping},
SelectedID => $Self->{EventTypeFilter}->{EventTypeID},
Translation => 1,
Multiple => 1,
Sort => 'AlphanumericValue',
Name => 'EventTypeFilter',
Class => 'Modernize',
);
# send data to JS
$LayoutObject->AddJSData(
Key => 'ArticleFilterDialog',
Value => 0,
);
$LayoutObject->Block(
Name => 'EventTypeFilterDialog',
Data => {%Param},
);
}
else {
my @CommunicationChannels = $Kernel::OM->Get('Kernel::System::CommunicationChannel')->ChannelList(
ValidID => 1,
);
my %Channels = map { $_->{ChannelID} => $_->{DisplayName} } @CommunicationChannels;
# build article type list for filter dialog
$Param{Channels} = $LayoutObject->BuildSelection(
Data => \%Channels,
SelectedID => $Self->{ArticleFilter}->{CommunicationChannelID},
Translation => 1,
Multiple => 1,
Sort => 'AlphanumericValue',
Name => 'CommunicationChannelFilter',
Class => 'Modernize',
);
$Param{CustomerVisibility} = $LayoutObject->BuildSelection(
Data => {
0 => Translatable('Invisible only'),
1 => Translatable('Visible only'),
2 => Translatable('Visible and invisible'),
},
SelectedID => $Self->{ArticleFilter}->{CustomerVisibility} // 2,
Translation => 1,
Sort => 'NumericKey',
Name => 'CustomerVisibilityFilter',
Class => 'Modernize',
);
# get sender types
my %ArticleSenderTypes = $ArticleObject->ArticleSenderTypeList();
# build article sender type list for filter dialog
$Param{ArticleSenderTypeFilterString} = $LayoutObject->BuildSelection(
Data => \%ArticleSenderTypes,
SelectedID => $Self->{ArticleFilter}->{ArticleSenderTypeID},
Translation => 1,
Multiple => 1,
Sort => 'AlphanumericValue',
Name => 'ArticleSenderTypeFilter',
Class => 'Modernize',
);
# Ticket ID
$Param{TicketID} = $Self->{TicketID};
# send data to JS
$LayoutObject->AddJSData(
Key => 'ArticleFilterDialog',
Value => 1,
);
$LayoutObject->Block(
Name => 'ArticleFilterDialog',
Data => {%Param},
);
}
}
# check if ticket need to be marked as seen
my $ArticleAllSeen = 1;
ARTICLE:
for my $Article (@ArticleBox) {
# ignore system sender type
next ARTICLE
if $ConfigObject->Get('Ticket::NewArticleIgnoreSystemSender')
&& $ArticleSenderTypeList{ $Article->{SenderTypeID} } eq 'system';
# last ARTICLE if article was not shown
if ( !$ArticleFlags{ $Article->{ArticleID} }->{Seen} ) {
$ArticleAllSeen = 0;
last ARTICLE;
}
}
# mark ticket as seen if all article are shown
if ($ArticleAllSeen) {
$TicketObject->TicketFlagSet(
TicketID => $Self->{TicketID},
Key => 'Seen',
Value => 1,
UserID => $Self->{UserID},
);
}
# send data to JS
$LayoutObject->AddJSData(
Key => 'ArticleTableHeight',
Value => $LayoutObject->{UserTicketZoomArticleTableHeight},
);
$LayoutObject->AddJSData(
Key => 'Ticket::Frontend::HTMLArticleHeightDefault',
Value => $ConfigObject->Get('Ticket::Frontend::HTMLArticleHeightDefault'),
);
$LayoutObject->AddJSData(
Key => 'Ticket::Frontend::HTMLArticleHeightMax',
Value => $ConfigObject->Get('Ticket::Frontend::HTMLArticleHeightMax'),
);
$LayoutObject->AddJSData(
Key => 'Language',
Value => {
AttachmentViewMessage => Translatable(
'Article could not be opened! Perhaps it is on another article page?'
),
},
);
# init js
$LayoutObject->Block(
Name => 'TicketZoomInit',
);
# return output
return $LayoutObject->Output(
TemplateFile => 'AgentTicketZoom',
Data => { %Param, %Ticket, %AclAction },
);
}
sub _ArticleTree {
my ( $Self, %Param ) = @_;
my %Ticket = %{ $Param{Ticket} };
my %ArticleFlags = %{ $Param{ArticleFlags} };
my @ArticleBox = @{ $Param{ArticleBox} };
my $ArticleID = $Param{ArticleID};
my $TableClasses;
# get layout object
my $LayoutObject = $Kernel::OM->Get('Kernel::Output::HTML::Layout');
# build thread string
$LayoutObject->Block(
Name => 'Tree',
Data => {
%Param,
TableClasses => $TableClasses,
ZoomTimeline => $Self->{ZoomTimeline},
},
);
if ( $Param{Pagination} && !$Self->{ZoomTimeline} ) {
$LayoutObject->Block(
Name => 'ArticlePages',
Data => $Param{Pagination},
);
}
my @ArticleViews = (
{
Key => 'Collapse',
Value => Translatable('Show one article'),
},
{
Key => 'Expand',
Value => Translatable('Show all articles'),
},
);
# Add timeline view option only if enabled.
if ( $Kernel::OM->Get('Kernel::Config')->Get('TimelineViewEnabled') ) {
push @ArticleViews, {
Key => 'Timeline',
Value => Translatable('Show Ticket Timeline View'),
};
}
my $ArticleViewSelected = 'Collapse';
if ( $Self->{ZoomExpand} ) {
$ArticleViewSelected = 'Expand';
}
elsif ( $Self->{ZoomTimeline} ) {
$ArticleViewSelected = 'Timeline';
}
my $ArticleViewStrg = $LayoutObject->BuildSelection(
Data => \@ArticleViews,
SelectedID => $ArticleViewSelected,
Translation => 1,
Sort => 'AlphanumericValue',
Name => 'ArticleView',
Class => 'Modernize',
);
# Send data to JS.
$LayoutObject->AddJSData(
Key => 'ArticleViewStrg',
Value => $ArticleViewStrg,
);
$LayoutObject->AddJSData(
Key => 'ZoomExpand',
Value => $Self->{ZoomExpand},
);
# article filter is activated in sysconfig
if ( $Self->{ArticleFilterActive} ) {
# define highlight style for links if filter is active
my $HighlightStyle = 'menu';
if ( $Self->{ArticleFilter} ) {
$HighlightStyle = 'PriorityID-5';
}
# build article filter links
$LayoutObject->Block(
Name => 'ArticleFilterDialogLink',
Data => {
%Param,
HighlightStyle => $HighlightStyle,
},
);
# build article filter reset link only if filter is set
if (
( !$Self->{ZoomTimeline} && IsHashRefWithData( $Self->{ArticleFilter} ) )
|| ( $Self->{ZoomTimeline} && $Self->{EventTypeFilter} )
)
{
$LayoutObject->Block(
Name => 'ArticleFilterResetLink',
Data => {%Param},
);
}
}
# get needed objects
my $TicketObject = $Kernel::OM->Get('Kernel::System::Ticket');
my $ArticleObject = $Kernel::OM->Get('Kernel::System::Ticket::Article');
my $ConfigObject = $Kernel::OM->Get('Kernel::Config');
# Create a list of article sender types for lookup
my %ArticleSenderTypeList = $ArticleObject->ArticleSenderTypeList();
# show article tree
if ( !$Self->{ZoomTimeline} ) {
$LayoutObject->Block(
Name => 'ArticleList',
Data => {
%Param,
ZoomExpandSortOrder => $Self->{ZoomExpandSort} eq 'reverse' ? 'Descending' : 'Ascending',
TableClasses => $TableClasses,
},
);
ARTICLE:
for my $ArticleTmp (@ArticleBox) {
my %Article = %$ArticleTmp;
# article filter is activated in sysconfig and there are articles
# that passed the filter
if ( $Self->{ArticleFilterActive} ) {
if ( $Self->{ArticleFilter} && $Self->{ArticleFilter}->{ShownArticleIDs} ) {
# do not show article in tree if it does not match the filter
if ( !$Self->{ArticleFilter}->{ShownArticleIDs}->{ $Article{ArticleID} } ) {
next ARTICLE;
}
}
}
# show article flags
my $Class = '';
my $ClassRow = '';
my $NewArticle = 0;
# ignore system sender types
if (
!$ArticleFlags{ $Article{ArticleID} }->{Seen}
&& (
!$ConfigObject->Get('Ticket::NewArticleIgnoreSystemSender')
|| $ConfigObject->Get('Ticket::NewArticleIgnoreSystemSender')
&& $ArticleSenderTypeList{ $Article{SenderTypeID} } ne 'system'
)
)
{
$NewArticle = 1;
# show ticket flags
# always show archived tickets as seen
if ( $Ticket{ArchiveFlag} ne 'y' ) {
$Class .= ' UnreadArticles';
$ClassRow .= ' UnreadArticles';
}
# just show ticket flags if agent belongs to the ticket
my $ShowMeta;
if (
$Self->{UserID} == $Ticket{OwnerID}
|| $Self->{UserID} == $Ticket{ResponsibleID}
)
{
$ShowMeta = 1;
}
if ( !$ShowMeta && $ConfigObject->Get('Ticket::Watcher') ) {
my %Watch = $TicketObject->TicketWatchGet(
TicketID => $Article{TicketID},
);
if ( $Watch{ $Self->{UserID} } ) {
$ShowMeta = 1;
}
}
# show ticket flags
if ($ShowMeta) {
$Class .= ' Remarkable';
}
else {
$Class .= ' Ordinary';
}
}
# if this is the shown article -=> set class to active
if ( $ArticleID eq $Article{ArticleID} && !$Self->{ZoomExpand} ) {
$ClassRow .= ' Active';
}
my $TmpSubject = $TicketObject->TicketSubjectClean(
TicketNumber => $Ticket{TicketNumber},
Subject => $Article{Subject} || '',
);
my %ArticleFields = $LayoutObject->ArticleFields(
%Article,
ShowDeletedArticles => $Self->{ShowDeletedArticles}
);
# Get transmission status information for email articles.
my $TransmissionStatus;
if ( $Article{ChannelName} && $Article{ChannelName} eq 'Email' ) {
$TransmissionStatus = $ArticleObject->BackendForArticle(%Article)->ArticleTransmissionStatus(
ArticleID => $Article{ArticleID},
ShowDeletedArticles => $Self->{ShowDeletedArticles}
);
}
# check if we need to show also expand/collapse icon
$LayoutObject->Block(
Name => 'TreeItem',
Data => {
%Article,
ArticleFields => \%ArticleFields,
Class => $Class,
ClassRow => $ClassRow,
Subject => $TmpSubject,
TransmissionStatus => $TransmissionStatus,
ZoomExpand => $Self->{ZoomExpand},
ZoomExpandSort => $Self->{ZoomExpandSort},
},
);
# get article flags
# Always use user id 1 because other users also have to see the important flag
my %ArticleImportantFlags = $ArticleObject->ArticleFlagGet(
ArticleID => $Article{ArticleID},
UserID => 1,
);
# show important flag
if ( $ArticleImportantFlags{Important} ) {
$LayoutObject->Block(
Name => 'TreeItemImportantArticle',
Data => {},
);
}
# always show archived tickets as seen
if ( $NewArticle && $Ticket{ArchiveFlag} ne 'y' ) {
$LayoutObject->Block(
Name => 'TreeItemNewArticle',
Data => {
%Article,
Class => $Class,
},
);
}
# Bugfix for IE7: a table cell should not be empty
# (because otherwise the cell borders are not shown):
# we add an empty element here
else {
$LayoutObject->Block(
Name => 'TreeItemNoNewArticle',
Data => {},
);
}
# Determine communication direction.
if ( $Article{ChannelName} eq 'Internal' ) {
$LayoutObject->Block( Name => 'TreeItemDirectionInternal' );
}
elsif ( $ArticleSenderTypeList{ $Article{SenderTypeID} } eq 'customer' ) {
$LayoutObject->Block( Name => 'TreeItemDirectionIncoming' );
}
else {
$LayoutObject->Block( Name => 'TreeItemDirectionOutgoing' );
}
# Get attachment index (excluding body attachments).
my %AtmIndex;
if ( !$Article{ArticleDeleted} || $Self->{ArticleStorage} =~ m/ArticleStorageFS/ ) {
%AtmIndex = $ArticleObject->BackendForArticle(%Article)->ArticleAttachmentIndex(
ArticleID => $Article{ArticleID},
ShowDeletedArticles => $Self->{ShowDeletedArticles},
%{ $Self->{ExcludeAttachments} },
);
}
else {
%AtmIndex = $ArticleObject->BackendForArticle(%Article)->ArticleAttachmentIndex(
ArticleID => $Article{DeletedVersionID},
SourceArticleID => $Article{ArticleID},
ShowDeletedArticles => $Self->{ShowDeletedArticles},
VersionView => 1,
%{ $Self->{ExcludeAttachments} }
);
}
$Article{Atms} = \%AtmIndex;
# show attachment info
# Bugfix for IE7: a table cell should not be empty
# (because otherwise the cell borders are not shown):
# we add an empty element here
if ( !$Article{Atms} || !%{ $Article{Atms} } ) {
$LayoutObject->Block(
Name => 'TreeItemNoAttachment',
Data => {},
);
next ARTICLE;
}
else {
my $Attachments = $Self->_CollectArticleAttachments(
Article => \%Article,
);
$LayoutObject->Block(
Name => 'TreeItemAttachment',
Data => {
TicketID => $Article{TicketID},
ArticleID => $Article{ArticleID},
Attachments => $Attachments,
},
);
}
}
}
# show timeline view
else {
# get ticket history
my @HistoryLines = $TicketObject->HistoryGet(
TicketID => $Self->{TicketID},
UserID => $Self->{UserID},
);
# get articles for later use
my @TimelineArticleBox = $ArticleObject->ArticleList(
TicketID => $Self->{TicketID},
ShowDeletedArticles => $Self->{ShowDeletedArticles}
);
for my $ArticleItem (@TimelineArticleBox) {
my $ArticleBackendObject = $ArticleObject->BackendForArticle(
%{$ArticleItem},
ShowDeletedArticles => $Self->{ShowDeletedArticles}
);
my %Article = $ArticleBackendObject->ArticleGet(
TicketID => $Self->{TicketID},
ArticleID => $ArticleItem->{ArticleID},
DynamicFields => 1,
RealNames => 1,
);
# Append article meta data.
$ArticleItem = {
%{$ArticleItem},
%Article,
};
}
my $ArticlesByArticleID = {};
for my $Article ( sort @TimelineArticleBox ) {
my $ArticleBackendObject = $ArticleObject->BackendForArticle(
%{$Article},
ShowDeletedArticles => $Self->{ShowDeletedArticles}
);
# Get attachment index (excluding body attachments).
my %AtmIndex = $ArticleBackendObject->ArticleAttachmentIndex(
ArticleID => $Article->{ArticleID},
%{ $Self->{ExcludeAttachments} },
);
$Article->{Atms} = \%AtmIndex;
$Article->{Backend} = $ArticleBackendObject->ChannelNameGet();
$ArticlesByArticleID->{ $Article->{ArticleID} } = $Article;
# Check if there is HTML body attachment.
my %AttachmentIndexHTMLBody = $ArticleBackendObject->ArticleAttachmentIndex(
ArticleID => $Article->{ArticleID},
OnlyHTMLBody => 1,
);
( $Article->{HTMLBodyAttachmentID} ) = sort keys %AttachmentIndexHTMLBody;
}
# do not display these types
my @TypesDodge = qw(
Misc
ArchiveFlagUpdate
LoopProtection
Remove
Subscribe
Unsubscribe
SystemRequest
SendAgentNotification
SendCustomerNotification
SendAutoReject
);
# sort out non-filtered event types (if applicable)
if (
$Self->{EventTypeFilter}->{EventTypeID}
&& IsArrayRefWithData( $Self->{EventTypeFilter}->{EventTypeID} )
)
{
for my $EventType ( sort keys %{ $Self->{HistoryTypeMapping} } ) {
if (
$EventType ne 'NewTicket' && !grep { $_ eq $EventType }
@{ $Self->{EventTypeFilter}->{EventTypeID} }
)
{
push @TypesDodge, $EventType;
}
}
}
# types which can be described as 'action on a ticket'
my @TypesTicketAction = qw(
ServiceUpdate
SLAUpdate
StateUpdate
SetPendingTime
Unlock
Lock
ResponsibleUpdate
OwnerUpdate
CustomerUpdate
NewTicket
TicketLinkAdd
TicketLinkDelete
TicketDynamicFieldUpdate
Move
Merged
PriorityUpdate
TitleUpdate
TypeUpdate
EscalationResponseTimeNotifyBefore
EscalationResponseTimeStart
EscalationResponseTimeStop
EscalationSolutionTimeNotifyBefore
EscalationSolutionTimeStart
EscalationSolutionTimeStop
EscalationUpdateTimeNotifyBefore
EscalationUpdateTimeStart
EscalationUpdateTimeStop
TimeAccounting
);
# types which are usually being connected to some kind of
# automatic process (e.g. triggered by another action)
my @TypesTicketAutoAction = qw(
SendAutoFollowUp
SendAutoReject
SendAutoReply
);
# types which can be considered as internal
my @TypesInternal = qw(
AddNote
ChatInternal
EmailAgentInternal
);
# outgoing types
my @TypesOutgoing = qw(
AddSMS
Forward
EmailAgent
PhoneCallAgent
Bounce
SendAnswer
);
# incoming types
my @TypesIncoming = qw(
EmailCustomer
AddNoteCustomer
AddSMSCustomer
PhoneCallCustomer
FollowUp
WebRequestCustomer
ChatExternal
);
my @TypesRight = (
@TypesIncoming,
@TypesTicketAction,
);
my @TypesWithArticles = (
@TypesOutgoing,
@TypesInternal,
@TypesIncoming,
'PhoneCallCustomer',
);
my %HistoryItems;
my $ItemCounter = 0;
my $LastCreateTime;
my $LastCreateSystemTime;
# Get mapping of history types to readable strings
my %HistoryTypes;
my %HistoryTypeConfig = %{ $Kernel::OM->Get('Kernel::Config')->Get('Ticket::Frontend::HistoryTypes') // {} };
for my $Entry ( sort keys %HistoryTypeConfig ) {
%HistoryTypes = (
%HistoryTypes,
%{ $HistoryTypeConfig{$Entry} },
);
}
HISTORYITEM:
for my $Item ( reverse @HistoryLines ) {
# special treatment for certain types, e.g. external notes from customers
if (
$Item->{ArticleID}
&& $Item->{HistoryType} eq 'AddNote'
&& IsHashRefWithData( $ArticlesByArticleID->{ $Item->{ArticleID} } )
&& $ArticleSenderTypeList{ $ArticlesByArticleID->{ $Item->{ArticleID} }->{SenderTypeID} } eq 'customer'
)
{
$Item->{Class} = 'TypeIncoming';
# We fake a custom history type because external notes from customers still
# have the history type 'AddNote' which does not allow for distinguishing.
$Item->{HistoryType} = 'AddNoteCustomer';
}
# special treatment for certain types, e.g. external SMS from customers
elsif (
$Item->{ArticleID}
&& $Item->{HistoryType} eq 'AddSMS'
&& IsHashRefWithData( $ArticlesByArticleID->{ $Item->{ArticleID} } )
&& $ArticleSenderTypeList{ $ArticlesByArticleID->{ $Item->{ArticleID} }->{SenderTypeID} } eq 'customer'
)
{
$Item->{Class} = 'TypeIncoming';
# We fake a custom history type because external notes from customers still
# have the history type 'AddSMS' which does not allow for distinguishing.
$Item->{HistoryType} = 'AddSMSCustomer';
}
# special treatment for internal emails
elsif (
$Item->{ArticleID}
&& $Item->{HistoryType} eq 'EmailAgent'
&& IsHashRefWithData( $ArticlesByArticleID->{ $Item->{ArticleID} } )
&& $ArticlesByArticleID->{ $Item->{ArticleID} }->{Backend} eq 'Email'
&& $ArticleSenderTypeList{ $ArticlesByArticleID->{ $Item->{ArticleID} }->{SenderTypeID} } eq 'agent'
&& !$ArticlesByArticleID->{ $Item->{ArticleID} }->{IsVisibleForCustomer}
)
{
$Item->{Class} = 'TypeNoteInternal';
$Item->{HistoryType} = 'EmailAgentInternal';
}
# special treatment for certain types, e.g. external notes from customers
elsif (
$Item->{ArticleID}
&& IsHashRefWithData( $ArticlesByArticleID->{ $Item->{ArticleID} } )
&& $ArticlesByArticleID->{ $Item->{ArticleID} }->{Backend} eq 'Chat'
&& $ArticlesByArticleID->{ $Item->{ArticleID} }->{IsVisibleForCustomer}
)
{
$Item->{HistoryType} = 'ChatExternal';
$Item->{Class} = 'TypeIncoming';
}
elsif (
$Item->{ArticleID}
&& IsHashRefWithData( $ArticlesByArticleID->{ $Item->{ArticleID} } )
&& $ArticlesByArticleID->{ $Item->{ArticleID} }->{Backend} eq 'Chat'
&& !$ArticlesByArticleID->{ $Item->{ArticleID} }->{IsVisibleForCustomer}
)
{
$Item->{HistoryType} = 'ChatInternal';
$Item->{Class} = 'TypeInternal';
}
elsif (
$Item->{HistoryType} eq 'Forward'
&& $Item->{ArticleID}
&& IsHashRefWithData( $ArticlesByArticleID->{ $Item->{ArticleID} } )
&& $ArticlesByArticleID->{ $Item->{ArticleID} }->{Backend} eq 'Email'
&& $ArticleSenderTypeList{ $ArticlesByArticleID->{ $Item->{ArticleID} }->{SenderTypeID} } eq 'agent'
&& !$ArticlesByArticleID->{ $Item->{ArticleID} }->{IsVisibleForCustomer}
)
{
$Item->{Class} = 'TypeNoteInternal';
}
elsif ( grep { $_ eq $Item->{HistoryType} } @TypesTicketAction ) {
$Item->{Class} = 'TypeTicketAction';
}
elsif ( grep { $_ eq $Item->{HistoryType} } @TypesTicketAutoAction ) {
$Item->{Class} = 'TypeTicketAutoAction';
}
elsif ( grep { $_ eq $Item->{HistoryType} } @TypesInternal ) {
$Item->{Class} = 'TypeNoteInternal';
}
elsif ( grep { $_ eq $Item->{HistoryType} } @TypesIncoming ) {
$Item->{Class} = 'TypeIncoming';
}
elsif ( grep { $_ eq $Item->{HistoryType} } @TypesOutgoing ) {
$Item->{Class} = 'TypeOutgoing';
}
if ( grep { $_ eq $Item->{HistoryType} } @TypesDodge ) {
next HISTORYITEM;
}
$Item->{Counter} = $ItemCounter++;
if ( $Item->{HistoryType} eq 'NewTicket' ) {
# if the 'NewTicket' item has an article, display this "creation article" event separately
if ( $Item->{ArticleID} ) {
push @{ $Param{Items} }, {
%{$Item},
Counter => $Item->{Counter}++,
Class => 'NewTicket',
Name => '',
ArticleID => '',
HistoryTypeReadable => Translatable('Ticket Created'),
Orientation => 'Right',
};
}
else {
$Item->{Class} = 'NewTicket';
delete $Item->{ArticleID};
delete $Item->{Name};
}
}
# remove article information from types which should not display articles
if ( !grep { $_ eq $Item->{HistoryType} } @TypesWithArticles ) {
delete $Item->{ArticleID};
}
# get article (if present)
if ( $Item->{ArticleID} ) {
$Item->{ArticleData} = $ArticlesByArticleID->{ $Item->{ArticleID} };
my %ArticleFields = $LayoutObject->ArticleFields(
TicketID => $Item->{ArticleData}->{TicketID},
ArticleID => $Item->{ArticleData}->{ArticleID},
ShowDeletedArticles => $Self->{ShowDeletedArticles}
);
$Item->{ArticleData}->{ArticleFields} = \%ArticleFields;
# Get dynamic fields and accounted time
my $Backend = $ArticlesByArticleID->{ $Item->{ArticleID} }->{Backend};
# Get dynamic fields and accounted time
my %ArticleMetaFields = $Kernel::OM->Get("Kernel::Output::HTML::TicketZoom::Agent::$Backend")->ArticleMetaFields(
TicketID => $Item->{ArticleData}->{TicketID},
ArticleID => $Item->{ArticleData}->{ArticleID},
UserID => $Self->{UserID},
);
$Item->{ArticleData}->{ArticleMetaFields} = \%ArticleMetaFields;
my @ArticleActions = $LayoutObject->ArticleActions(
TicketID => $Item->{ArticleData}->{TicketID},
ArticleID => $Item->{ArticleData}->{ArticleID},
Type => 'OnLoad',
ShowDeletedArticles => $Self->{ShowDeletedArticles}
);
$Item->{ArticleData}->{ArticlePlain} = $LayoutObject->ArticlePreview(
TicketID => $Item->{ArticleData}->{TicketID},
ArticleID => $Item->{ArticleData}->{ArticleID},
ResultType => 'plain',
);
$Item->{ArticleData}->{ArticleHTML} = $Kernel::OM->Get("Kernel::Output::HTML::TimelineView::$Backend")->ArticleRender(
TicketID => $Item->{ArticleData}->{TicketID},
ArticleID => $Item->{ArticleData}->{ArticleID},
ArticleActions => \@ArticleActions,
UserID => $Self->{UserID},
);
# remove empty lines
$Item->{ArticleData}->{ArticlePlain} =~ s{^[\n\r]+}{}xmsg;
# Modify plain text and body to avoid '' tag issue (see bug#14023).
$Item->{ArticleData}->{ArticlePlain} =~ s{}{<###/script>}xmsg;
$Item->{ArticleData}->{Body} =~ s{}{<###/script>}xmsg;
my %ArticleFlagsAll = $ArticleObject->ArticleFlagGet(
ArticleID => $Item->{ArticleID},
UserID => 1,
);
my %ArticleFlagsMe = $ArticleObject->ArticleFlagGet(
ArticleID => $Item->{ArticleID},
UserID => $Self->{UserID},
);
$Item->{ArticleData}->{ArticleIsImportant} = $ArticleFlagsAll{Important};
$Item->{ArticleData}->{ArticleIsSeen} = $ArticleFlagsMe{Seen};
}
else {
if ( $Item->{Name} && $Item->{Name} =~ m/^%%/x ) {
$Item->{Name} =~ s/^%%//xg;
my @Values = split( /%%/x, $Item->{Name} );
# See documentation in AgentTicketHistory.pm, line 141+
if ( $Item->{HistoryType} eq 'TicketDynamicFieldUpdate' ) {
@Values = ( $Values[1], $Values[5] // '', $Values[3] // '' );
}
elsif ( $Item->{HistoryType} eq 'TypeUpdate' ) {
@Values = ( $Values[2], $Values[3], $Values[0], $Values[1] );
}
$Item->{Name} = $LayoutObject->{LanguageObject}->Translate(
$HistoryTypes{ $Item->{HistoryType} },
@Values,
);
# remove not needed place holder
$Item->{Name} =~ s/\%s//xg;
}
}
# make the history type more readable (if applicable)
$Item->{HistoryTypeReadable} = $Self->{HistoryTypeMapping}->{ $Item->{HistoryType} }
|| $Item->{HistoryType};
# group items which happened (nearly) coincidently together
my $CreateSystemTimeObject = $Kernel::OM->Create(
'Kernel::System::DateTime',
ObjectParams => {
String => $Item->{CreateTime},
},
);
$Item->{CreateSystemTime} = $CreateSystemTimeObject
? $CreateSystemTimeObject->ToEpoch()
: undef;
# if we have two events that happened 'nearly' the same time, treat
# them as if they happened exactly on the same time (threshold 5 seconds)
if (
$LastCreateSystemTime
&& $Item->{CreateSystemTime} <= $LastCreateSystemTime
&& $Item->{CreateSystemTime} >= ( $LastCreateSystemTime - 5 )
)
{
push @{ $HistoryItems{$LastCreateTime} }, $Item;
$Item->{CreateTime} = $LastCreateTime;
}
else {
push @{ $HistoryItems{ $Item->{CreateTime} } }, $Item;
}
$LastCreateTime = $Item->{CreateTime};
$LastCreateSystemTime = $Item->{CreateSystemTime};
}
my $SortByArticle = sub {
my $IsA = grep { $_ eq $a->{HistoryType} } @TypesWithArticles;
my $IsB = grep { $_ eq $b->{HistoryType} } @TypesWithArticles;
$IsB cmp $IsA;
};
# sort history items based on items with articles
# these items should always be on top of a list of connected items
$ItemCounter = 0;
for my $Item ( reverse sort keys %HistoryItems ) {
for my $SubItem ( sort $SortByArticle @{ $HistoryItems{$Item} } ) {
$SubItem->{Counter} = $ItemCounter++;
if ( grep { $_ eq $SubItem->{HistoryType} } @TypesRight ) {
$SubItem->{Orientation} = 'Right';
}
else {
$SubItem->{Orientation} = 'Left';
}
push @{ $Param{Items} }, $SubItem;
}
}
# set TicketID for usage in JS
$Param{TicketID} = $Self->{TicketID};
# set key 'TimeLong' for JS
for my $Item ( @{ $Param{Items} } ) {
$Item->{TimeLong} = $LayoutObject->{LanguageObject}->FormatTimeString( $Item->{CreateTime}, 'DateFormatLong' );
}
for my $ArticleID ( sort keys %{$ArticlesByArticleID} ) {
# Check if article has attachment(s).
if ( IsHashRefWithData( $ArticlesByArticleID->{$ArticleID}->{Atms} ) ) {
my ($Index) = grep { $Param{Items}->[$_]->{ArticleID} && $Param{Items}->[$_]->{ArticleID} == $ArticleID }
0 .. @{ $Param{Items} };
$Param{Items}->[$Index]->{HasAttachment} = 1;
}
}
# Get NoTimelineViewAutoArticle config value for usage in JS.
$LayoutObject->AddJSData(
Key => 'NoTimelineViewAutoArticle',
Value => $ConfigObject->Get('NoTimelineViewAutoArticle') || '0',
);
# Include current article ID only if it's selected.
$Param{CurrentArticleID} //= $Self->{ArticleID};
# Modify body text to avoid '' tag issue (see bug#14023).
for my $ArticleBoxItem (@ArticleBox) {
$ArticleBoxItem->{Body} =~ s{}{<###/script>}xmsg;
}
# send data to JS
$LayoutObject->AddJSData(
Key => 'TimelineView',
Value => {
Enabled => $ConfigObject->Get('TimelineViewEnabled'),
Data => \%Param,
},
);
$LayoutObject->Block(
Name => 'TimelineView',
Data => \%Param,
);
# jump to selected article
if ( $Self->{ArticleID} ) {
$LayoutObject->Block(
Name => 'ShowSelectedArticle',
Data => {
ArticleID => $Self->{ArticleID},
},
);
}
# render action menu for all articles
for my $ArticleID ( sort keys %{$ArticlesByArticleID} ) {
# show attachments box
if ( IsHashRefWithData( $ArticlesByArticleID->{$ArticleID}->{Atms} ) ) {
my $ArticleAttachments = $Self->_CollectArticleAttachments(
Article => $ArticlesByArticleID->{$ArticleID},
);
$LayoutObject->Block(
Name => 'TimelineViewArticleAttachments',
Data => {
TicketID => $Self->{TicketID},
ArticleID => $ArticleID,
Attachments => $ArticleAttachments,
},
);
}
}
}
# return output
return $LayoutObject->Output(
TemplateFile => 'AgentTicketZoom',
Data => { %Param, %Ticket },
);
}
sub _TicketItemSeen {
my ( $Self, %Param ) = @_;
my @Articles = $Kernel::OM->Get('Kernel::System::Ticket::Article')->ArticleList(
TicketID => $Param{TicketID},
ShowDeletedArticles => $Self->{ShowDeletedArticles}
);
for my $Article (@Articles) {
$Self->_ArticleItemSeen(
TicketID => $Param{TicketID},
ArticleID => $Article->{ArticleID},
);
}
return 1;
}
sub _ArticleItemSeen {
my ( $Self, %Param ) = @_;
my $IsArticleDeleted = $Kernel::OM->Get('Kernel::System::Ticket::ArticleFeatures')->IsArticleDeleted(
ArticleID => $Param{ArticleID}
);
# mark shown article as seen
$Kernel::OM->Get('Kernel::System::Ticket::Article')->ArticleFlagSet(
TicketID => $Param{TicketID},
ArticleID => $Param{ArticleID},
Key => 'Seen',
Value => 1,
UserID => $Self->{UserID},
ArticleDeleted => $IsArticleDeleted
);
return 1;
}
sub _ArticleItem {
my ( $Self, %Param ) = @_;
my %Ticket = %{ $Param{Ticket} };
my %Article = %{ $Param{Article} };
my $LayoutObject = $Kernel::OM->Get('Kernel::Output::HTML::Layout');
# show article actions
my @MenuItems = $LayoutObject->ArticleActions(
%Param,
TicketID => $Param{Ticket}->{TicketID},
ArticleID => $Param{Article}->{ArticleID},
Type => $Param{Type},
ShowDeletedArticles => $Self->{ShowDeletedArticles}
);
push @{ $Self->{MenuItems} }, \@MenuItems;
# TODO: Review
return $Self->_ArticleRender(
TicketID => $Ticket{TicketID},
ArticleID => $Article{ArticleID},
UserID => $Self->{UserID},
ShowBrowserLinkMessage => $Self->{DoNotShowBrowserLinkMessage} ? 0 : 1,
Type => $Param{Type},
MenuItems => \@MenuItems,
ShowDeletedArticles => $Self->{ShowDeletedArticles}
);
}
sub _CollectArticleAttachments {
my ( $Self, %Param ) = @_;
my %Article = %{ $Param{Article} };
my %Attachments;
# get config object
my $ConfigObject = $Kernel::OM->Get('Kernel::Config');
# download type
my $Type = $ConfigObject->Get('AttachmentDownloadType') || 'attachment';
$Article{AtmCount} = scalar keys %{ $Article{Atms} // {} };
# if attachment will be forced to download, don't open a new download window!
my $Target = 'target="AttachmentWindow" ';
if ( $Type =~ /inline/i ) {
$Target = 'target="attachment" ';
}
$Attachments{ZoomAttachmentDisplayCount} = $ConfigObject->Get('Ticket::ZoomAttachmentDisplayCount');
ATTACHMENT:
for my $FileID ( sort keys %{ $Article{Atms} } ) {
push @{ $Attachments{Files} }, {
ArticleID => $Article{ArticleID},
%{ $Article{Atms}->{$FileID} },
FileID => $FileID,
Target => $Target,
};
}
return \%Attachments;
}
sub _ArticleBoxGet {
my ( $Self, %Param ) = @_;
# Check needed stuff.
for my $Needed (qw(Page ArticleBoxAll Limit)) {
if ( !$Param{$Needed} ) {
$Kernel::OM->Get('Kernel::System::Log')->Log(
Priority => 'error',
Message => "Need $Needed!",
);
return;
}
}
my $ArticleObject = $Kernel::OM->Get('Kernel::System::Ticket::Article');
my $Start = ( $Param{Page} - 1 ) * $Param{Limit};
my $End = $Param{Page} * $Param{Limit} - 1;
if ( $End >= scalar @{ $Param{ArticleBoxAll} } ) {
# Make sure that end index doesn't exceed array size.
$End = scalar @{ $Param{ArticleBoxAll} } - 1;
}
my @ArticleIndexes = ( $Start .. $End );
my $CommunicationChannelObject = $Kernel::OM->Get('Kernel::System::CommunicationChannel');
# Save communication channel data to improve performance.
my %CommunicationChannelData;
my @ArticleBox;
for my $Index (@ArticleIndexes) {
my $ArticleBackendObject = $ArticleObject->BackendForArticle(
TicketID => $Self->{TicketID},
ArticleID => $Param{ArticleBoxAll}->[$Index]->{ArticleID},
ShowDeletedArticles => $Self->{ShowDeletedArticles}
);
my %Article = $ArticleBackendObject->ArticleGet(
TicketID => $Self->{TicketID},
ArticleID => $Param{ArticleBoxAll}->[$Index]->{ArticleID},
DynamicFields => 1,
RealNames => 1,
);
# Include some information about communication channel.
if ( !$CommunicationChannelData{ $Article{CommunicationChannelID} } ) {
# Communication channel display name is part of the configuration.
my %CommunicationChannel = $CommunicationChannelObject->ChannelGet(
ChannelID => $Article{CommunicationChannelID},
);
# Presence of communication channel object indicates its validity.
my $ChannelObject = $CommunicationChannelObject->ChannelObjectGet(
ChannelID => $Article{CommunicationChannelID},
);
$CommunicationChannelData{ $Article{CommunicationChannelID} } = {
ChannelName => $CommunicationChannel{ChannelName},
ChannelDisplayName => $CommunicationChannel{DisplayName},
ChannelInvalid => !$ChannelObject,
};
}
%Article = ( %Article, %{ $CommunicationChannelData{ $Article{CommunicationChannelID} } } );
push @ArticleBox, \%Article;
}
return @ArticleBox;
}
=head2 _ArticleRender()
Returns article html.
my $HTML = $Self->_ArticleRender(
TicketID => 123, # (required)
ArticleID => 123, # (required)
Type => 'Static', # (required) Static or OnLoad
ShowBrowserLinkMessage => 1, # (optional)
);
Result:
$HTML = "
...
";
=cut
sub _ArticleRender {
my ( $Self, %Param ) = @_;
# Check needed stuff.
for my $Needed (qw(TicketID ArticleID Type)) {
if ( !$Param{$Needed} ) {
$Kernel::OM->Get('Kernel::System::Log')->Log(
Priority => 'error',
Message => "Need $Needed!",
);
return;
}
}
# Get article data.
my $ArticleBackendObject = $Kernel::OM->Get('Kernel::System::Ticket::Article')->BackendForArticle(%Param);
# Determine channel name for this Article.
my $ChannelName = $ArticleBackendObject->ChannelNameGet();
my $Loaded = $Kernel::OM->Get('Kernel::System::Main')->Require(
"Kernel::Output::HTML::TicketZoom::Agent::$ChannelName",
);
return if !$Loaded;
return $Kernel::OM->Get("Kernel::Output::HTML::TicketZoom::Agent::$ChannelName")->ArticleRender(
%Param,
ArticleActions => $Param{MenuItems},
UserID => $Self->{UserID},
);
}
sub _GetIncludedFieldOrdered {
my ( $Self, %Param ) = @_;
my @Return;
ITEM:
for my $IncludeItem ( @{ $Param{Include} } ) {
if ( $IncludeItem->{Grid} ) {
for my $Row ( @{ $IncludeItem->{Grid}{Rows} } ) {
COLUMN:
for my $DFEntry ( $Row->@* ) {
next COLUMN if !$DFEntry->{DF};
push @Return, $DFEntry->{DF};
}
}
}
elsif ( $IncludeItem->{DF} ) {
push @Return, $IncludeItem->{DF};
}
}
return @Return;
}
1;