Adds datepicker and improved graph/piechart
Adds ability to control time period shown on graph and pie chart in reporting tab. Preset range buttons have been included under two dropdown buttons, and it is possible to see the previous/next set range with the arrow buttons. Interactive legends have been added to both the datepicker and the piechart. Change-Id: Ieea8f22a5ac7e21996d4bd4223ea01694591bd72 Signed-off-by: Leonie Chamberlin-Medd <leonie@stackhpc.com>
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% block trimmed %}
|
||||
<div class="input-group date">
|
||||
{{ datepicker_input }}
|
||||
<label class="sr-only">{{ datepicker_label }}</label>
|
||||
<span class="input-group-addon datepicker-addon">
|
||||
<span class="fa fa-calendar"></span>
|
||||
</span>
|
||||
</div>
|
||||
<script>
|
||||
$(function () {
|
||||
$('.datepicker input').datepicker({
|
||||
todayBtn: "linked", // Enables the today button which quickly links back to today
|
||||
endDate: '0d', // Prevents the user from picking a date in the future
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,61 @@
|
||||
{% load i18n %}
|
||||
{%load static %}
|
||||
|
||||
<link rel="stylesheet" href="{% static 'cloudkitty/css/datepicker.css' %}">
|
||||
<script src="{% static 'cloudkitty/js/datepicker.js' %}" type="text/javascript" charset="utf-8"></script>
|
||||
|
||||
<form action="?" method="get" id="{{ datepicker_id }}" class="form-inline">
|
||||
<h4>{% trans "Select a period of time to view data in:" %}
|
||||
<span class="small help-block">{% trans "The date should be in YYYY-MM-DD format." %}</span>
|
||||
</h4>
|
||||
<div class="datepicker form-group">
|
||||
{% with datepicker_input=form.start datepicker_label="From" %}
|
||||
{% include 'project/reporting/_datepicker_reporting.html' %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<span class="datepicker-delimiter">
|
||||
<span class="datepicker-delimiter-text">{% trans 'to' %}</span>
|
||||
</span>
|
||||
<div class="datepicker form-group">
|
||||
{% with datepicker_input=form.end datepicker_label="To" %}
|
||||
{% include 'project/reporting/_datepicker_reporting.html' %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
|
||||
</form>
|
||||
|
||||
<div class="controls-container">
|
||||
<div class="dropdown default-ranges">
|
||||
<button class="dropbtn">{% trans "Period Range" %}</button>
|
||||
<div class="dropdown-content">
|
||||
<button data-period="day" data-view="current">{% trans "Day View" %}</button>
|
||||
<button data-period="week" data-view="current">{% trans "Week View" %}</button>
|
||||
<button data-period="month" data-view="current">{% trans "Month View" %}</button>
|
||||
<button data-period="year" data-view="current">{% trans "Year View" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown preset-ranges">
|
||||
<button class="dropbtn">{% trans "Preset Ranges" %}</button>
|
||||
<div class="dropdown-content">
|
||||
<button data-period="day" data-view="yesterday">{% trans "Yesterday" %}</button>
|
||||
<button data-period="week" data-view="last">{% trans "Last Week" %}</button>
|
||||
<button data-period="month" data-view="last">{% trans "Last Month" %}</button>
|
||||
<button data-period="month" data-view="last" data-amount="3">{% trans "Last 3 Months" %}</button>
|
||||
<button data-period="month" data-view="last" data-amount="6">{% trans "Last 6 Months" %}</button>
|
||||
<button data-period="year" data-view="last">{% trans "Last Year" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="arrow-btn" data-direction="prev">«</button>
|
||||
<button class="arrow-btn" data-direction="next">»</button>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
$(function () {
|
||||
initDatepicker({
|
||||
formId: '{{ datepicker_id }}',
|
||||
startFieldName: '{{ form.start.name }}',
|
||||
endFieldName: '{{ form.end.name }}'
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -2,9 +2,12 @@
|
||||
{% load l10n %}
|
||||
{% load static %}
|
||||
|
||||
<link rel="stylesheet" href="{% static 'cloudkitty/css/this_month.css' %}">
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="col-lg-3 col-md-4">
|
||||
<h4>{% trans "Legend" %}</h4>
|
||||
<span class="small help-block">{% trans "Click on a metric to remove it from the pie chart." %}</span>
|
||||
<div id="graph_legend"></div>
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-8" style="max-width:25vw;">
|
||||
@@ -15,27 +18,39 @@
|
||||
<h4>{% trans "Cost Per Service Per Hour" %}</h4>
|
||||
<div id="cost_progress" style="max-width:100%;"></div>
|
||||
<div id="cost_progress_legend"></div>
|
||||
<div id="datepicker_range">
|
||||
{% with start=form.start end=form.end datepicker_id='date_form' %}
|
||||
{% include 'project/reporting/_datepicker_reporting_form.html' %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
var data = [
|
||||
{% for service, data in repartition_data.items %}
|
||||
{"label": "{{ service }}",
|
||||
"value": {{ data.cumulated|unlocalize }}
|
||||
},
|
||||
{% endfor %}
|
||||
<script>
|
||||
|
||||
window.addEventListener('load', function () {
|
||||
var data = [
|
||||
{% for service, data in repartition_data.items %}
|
||||
{
|
||||
"label": "{{ service }}",
|
||||
"value": {{ data.cumulated | unlocalize }}
|
||||
},
|
||||
{% endfor %}
|
||||
]
|
||||
|
||||
// Pie Chart
|
||||
var innerRadius = 75;
|
||||
var outerRadius = 150;
|
||||
var height = 300;
|
||||
var width = 300;
|
||||
data.forEach(function (d) {
|
||||
d.value = +d.value; // Ensure value is a number
|
||||
d.enabled = true;
|
||||
});
|
||||
|
||||
var colors = d3.scale.category20c();
|
||||
// Pie Chart
|
||||
var innerRadius = 75;
|
||||
var outerRadius = 150;
|
||||
var height = 300;
|
||||
var width = 300;
|
||||
|
||||
var vis = d3.select("#repartition_cumulated")
|
||||
var colors = d3.scale.category20c();
|
||||
|
||||
var vis = d3.select("#repartition_cumulated")
|
||||
.append("svg:svg") // create the SVG element inside the DOM
|
||||
.data([data]) // associate our data
|
||||
.attr("width", "75%")
|
||||
@@ -44,35 +59,78 @@
|
||||
.append("svg:g")
|
||||
.attr("transform", "translate(" + width / 2 + "," + height / 2 + ")") // move the center of the pie chart from 0, 0 to radius, radius
|
||||
|
||||
var arc = d3.svg.arc() // Creating <path> elements using arc data
|
||||
var arc = d3.svg.arc() // Creating <path> elements using arc data
|
||||
.outerRadius(outerRadius)
|
||||
.innerRadius(innerRadius);
|
||||
|
||||
var pie = d3.layout.pie() // Creating arc data for us given a list of values
|
||||
.value(function(d) { return d.value; });
|
||||
var pie = d3.layout.pie() // Creating arc data for us given a list of values
|
||||
.value(function (d) { return d.enabled ? d.value : 0; });
|
||||
|
||||
var arcs = vis.selectAll("g.slice") // Selecting all <g> elements (there are none yet)
|
||||
var arcs = vis.selectAll("g.slice") // Selecting all <g> elements (there are none yet)
|
||||
.data(pie) // associate data
|
||||
.enter() // creating a <g> for each element of data
|
||||
.append("svg:g")
|
||||
.attr("class", "slice");
|
||||
|
||||
arcs.append("svg:path")
|
||||
.attr("fill", function(d, i) { return colors(i); } ) // Setting the color of each slice
|
||||
.attr("d", arc); // creating the actual svg
|
||||
|
||||
arcs.append("svg:title") //add a label to each slice
|
||||
.attr("text-anchor", "middle") //center the text on it's origin
|
||||
.text(function(d, i) { return data[i].label; }); //get the label from our original data array
|
||||
var div = d3.select("body").append("div")
|
||||
.attr("class", "tooltip-donut")
|
||||
.style("position", "absolute")
|
||||
.style("text-align", "center")
|
||||
.style("padding", ".5rem")
|
||||
.style("background", "#FFFFFF")
|
||||
.style("color", "#313639")
|
||||
.style("border", "1px solid #313639")
|
||||
.style("border-radius", "8px")
|
||||
.style("pointer-events", "none")
|
||||
.style("font-size", "1.3rem")
|
||||
.style("opacity", 0);
|
||||
|
||||
var path = arcs.append("svg:path")
|
||||
.attr("fill", function (d, i) { return colors(i); }) // Setting the color of each slice
|
||||
.attr("d", arc) // creating the actual svg
|
||||
.each(function (d) { this._current = d; }) // store the initial angles
|
||||
.style("pointer-events", "none") // Disable hover events during initial animation
|
||||
.on('mouseover', function (d, i) {
|
||||
d3.select(this).transition()
|
||||
.duration('50')
|
||||
.attr('opacity', '.85');
|
||||
div.transition()
|
||||
.duration(50)
|
||||
.style("opacity", 1);
|
||||
|
||||
// Calculate total of all enabled values
|
||||
var total = d3.sum(data, function (item) {
|
||||
return item.enabled ? item.value : 0;
|
||||
});
|
||||
let percentage = total > 0 ? Math.round((d.value / total) * 100) : 0;
|
||||
let label = percentage.toString() + '%';
|
||||
|
||||
div.html(label) // shows percentage label to be near cursor
|
||||
.style("left", (d3.mouse(document.body)[0] + 10) + "px")
|
||||
.style("top", (d3.mouse(document.body)[1] - 15) + "px");
|
||||
})
|
||||
.on('mouseout', function (d, i) {
|
||||
d3.select(this).transition()
|
||||
.duration('50')
|
||||
.attr('opacity', '1');
|
||||
div.transition()
|
||||
.duration('50')
|
||||
.style("opacity", 0);
|
||||
});
|
||||
|
||||
arcs.append("svg:title") //add a label to each slice
|
||||
.attr("text-anchor", "middle") //center the text on its origin
|
||||
.text(function (d, i) { return data[i].label; }); //get the label from our original data array
|
||||
|
||||
|
||||
// Legend
|
||||
var legendHeight = 20;
|
||||
var legendSpace = 5;
|
||||
var viewBoxHeight = data.length * (legendHeight + legendSpace);
|
||||
console.log('data length', data.length)
|
||||
// Legend
|
||||
var legendHeight = 20;
|
||||
var legendSpace = 5;
|
||||
var viewBoxHeight = data.length * (legendHeight + legendSpace);
|
||||
console.log('data length', data.length)
|
||||
|
||||
var legend_vis = d3.select("#graph_legend")
|
||||
var legend_vis = d3.select("#graph_legend")
|
||||
.append("svg:svg")
|
||||
.data([data])
|
||||
.attr("viewBox", "0 0 250 " + viewBoxHeight)
|
||||
@@ -81,69 +139,153 @@
|
||||
.append("svg:g")
|
||||
.attr("transform", "translate(0,0)");
|
||||
|
||||
var legend = legend_vis.selectAll("g")
|
||||
var legend = legend_vis.selectAll("g")
|
||||
.data(data)
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', 'legend')
|
||||
.attr('transform', function(d, i) {
|
||||
.attr('transform', function (d, i) {
|
||||
var x = 0;
|
||||
var y = i * (legendHeight + legendSpace);
|
||||
return 'translate(' + x + ',' + y + ')';
|
||||
});
|
||||
|
||||
var legendRectSize = 20;
|
||||
var legendSpacing = 5;
|
||||
legend.append('rect')
|
||||
var legendRectSize = 20;
|
||||
var legendSpacing = 5;
|
||||
legend.append('rect')
|
||||
.attr('width', legendRectSize)
|
||||
.attr('height', legendRectSize)
|
||||
.style('fill', function(d, i) { return colors(i); })
|
||||
.style('stroke', function(d, i) { return colors(i); });
|
||||
.style('fill', function (d, i) { return colors(i); })
|
||||
.style('stroke', function (d, i) { return colors(i); })
|
||||
.on('click.removeSlice', function (d) {
|
||||
var rect = d3.select(this);
|
||||
var enabled = true;
|
||||
|
||||
legend.append('text')
|
||||
// Find the corresponding data point in the original data array
|
||||
var dataItem = data.find(item => item.label === d.label);
|
||||
|
||||
// Calculate total enabled slices
|
||||
var totalEnabled = d3.sum(data.map(function (d) {
|
||||
return (d.enabled) ? 1 : 0;
|
||||
}));
|
||||
|
||||
if (rect.attr('class') === 'disabled') {
|
||||
rect.attr('class', '');
|
||||
if (dataItem) dataItem.enabled = true; // Enable the data point
|
||||
}
|
||||
else {
|
||||
if (totalEnabled < 2) return; // Prevent disabling of the last slice
|
||||
rect.attr('class', 'disabled');
|
||||
if (dataItem) dataItem.enabled = false;
|
||||
}
|
||||
|
||||
// Rebind data to the pie slices, filtered by enabled state
|
||||
// You need to re-select 'arcs' and then re-bind data
|
||||
arcs = vis.selectAll("g.slice")
|
||||
.data(pie);
|
||||
|
||||
// Disable hover events during legend-triggered animation
|
||||
path.style("pointer-events", "none");
|
||||
|
||||
// Update the path 'd' attribute with a transition
|
||||
arcs.select("path")
|
||||
.transition()
|
||||
.duration(750)
|
||||
.attrTween('d', function (d) {
|
||||
var interpolate = d3.interpolate(this._current, d);
|
||||
this._current = interpolate(0); // Update _current for next transition
|
||||
return function (t) {
|
||||
return arc(interpolate(t));
|
||||
};
|
||||
})
|
||||
.each("end", function () {
|
||||
// Re-enable hover events after legend animation completes
|
||||
path.style("pointer-events", "auto");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
legend.append('text')
|
||||
.attr('x', legendRectSize + legendSpacing)
|
||||
.attr('y', legendRectSize - legendSpacing)
|
||||
.text(function(d) { return d.label; });
|
||||
</script>
|
||||
.text(function (d) { return d.label; });
|
||||
|
||||
<script>
|
||||
|
||||
var colors = d3.scale.category20c();
|
||||
var graph = new Rickshaw.Graph({
|
||||
element: document.querySelector('#cost_progress'),
|
||||
interpolation: 'linear',
|
||||
unstack: 'true',
|
||||
onComplete: function(w) {
|
||||
var legend = new Rickshaw.Graph.Legend({
|
||||
element: document.querySelector('#cost_progress_legend'),
|
||||
graph: w.graph
|
||||
path.transition()
|
||||
.ease("sin")
|
||||
.duration(2000)
|
||||
.attrTween("d", tweenPie)
|
||||
.each("end", function () {
|
||||
path.style("pointer-events", "auto");
|
||||
});
|
||||
},
|
||||
series: [
|
||||
{% for service, data in repartition_data.items %}
|
||||
|
||||
|
||||
function tweenPie(b) {
|
||||
var i = d3.interpolate({ startAngle: 0, endAngle: 0 }, b);
|
||||
return function (t) { return arc(i(t)); };
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<script>
|
||||
|
||||
var colors = d3.scale.category20c();
|
||||
var graph = new Rickshaw.Graph({
|
||||
element: document.querySelector('#cost_progress'),
|
||||
interpolation: 'linear',
|
||||
unstack: true,
|
||||
|
||||
series: [
|
||||
{% for service, data in repartition_data.items %}
|
||||
{
|
||||
color: colors({{ forloop.counter }} - 1),
|
||||
name: "{{ service }}",
|
||||
data: [
|
||||
{% for timestamp, rating in data.hourly.items %}{x: {{ timestamp }}, y: {{ rating|unlocalize }}},{% endfor %}
|
||||
{% for timestamp, rating in data.hourly.items %}{ x: {{ timestamp }}, y: {{ rating | unlocalize }} }, {% endfor %}
|
||||
]
|
||||
},
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
]
|
||||
});
|
||||
graph.render();
|
||||
graph.render();
|
||||
|
||||
var hoverDetail = new Rickshaw.Graph.HoverDetail( {
|
||||
graph: graph
|
||||
});
|
||||
var legend = new Rickshaw.Graph.Legend({
|
||||
element: document.querySelector('#cost_progress_legend'),
|
||||
graph: graph
|
||||
});
|
||||
|
||||
var yAxis = new Rickshaw.Graph.Axis.Y({
|
||||
graph: graph,
|
||||
});
|
||||
yAxis.render();
|
||||
var hoverDetail = new Rickshaw.Graph.HoverDetail({
|
||||
graph: graph
|
||||
});
|
||||
|
||||
var xAxis = new Rickshaw.Graph.Axis.Time({
|
||||
graph: graph
|
||||
});
|
||||
xAxis.render();
|
||||
</script>
|
||||
var yAxis = new Rickshaw.Graph.Axis.Y({
|
||||
graph: graph,
|
||||
});
|
||||
yAxis.render();
|
||||
|
||||
var xAxis = new Rickshaw.Graph.Axis.Time({
|
||||
graph: graph
|
||||
});
|
||||
xAxis.render();
|
||||
|
||||
// This allows you to toggle the visibility of series in the graph
|
||||
var shelving = new Rickshaw.Graph.Behavior.Series.Toggle({
|
||||
graph: graph,
|
||||
legend: legend
|
||||
});
|
||||
|
||||
// This allows you to highlight a series when you hover over it in the legend
|
||||
var highlighter = new Rickshaw.Graph.Behavior.Series.Highlight({
|
||||
graph: graph,
|
||||
legend: legend
|
||||
});
|
||||
|
||||
//This allows you to change which metric is in front
|
||||
var order = new Rickshaw.Graph.Behavior.Series.Order({
|
||||
graph: graph,
|
||||
legend: legend
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
@@ -18,9 +18,15 @@ import datetime
|
||||
import decimal
|
||||
import time
|
||||
|
||||
from horizon import messages
|
||||
from horizon import tabs
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from cloudkittydashboard.api import cloudkitty as api
|
||||
from cloudkittydashboard import forms
|
||||
|
||||
|
||||
def _do_this_month(data):
|
||||
@@ -33,9 +39,12 @@ def _do_this_month(data):
|
||||
end_timestamp = None
|
||||
for dataframe in data.get('dataframes', []):
|
||||
begin = dataframe['begin']
|
||||
timestamp = int(time.mktime(
|
||||
datetime.datetime.strptime(begin[:16],
|
||||
"%Y-%m-%dT%H:%M").timetuple()))
|
||||
timestamp = int(
|
||||
time.mktime(
|
||||
datetime.datetime.strptime(
|
||||
begin[:16], "%Y-%m-%dT%H:%M").timetuple()
|
||||
)
|
||||
)
|
||||
if start_timestamp is None or timestamp < start_timestamp:
|
||||
start_timestamp = timestamp
|
||||
if end_timestamp is None or timestamp > end_timestamp:
|
||||
@@ -69,25 +78,102 @@ def _do_this_month(data):
|
||||
|
||||
|
||||
class CostRepartitionTab(tabs.Tab):
|
||||
name = "This month"
|
||||
name = _("This month")
|
||||
slug = "this_month"
|
||||
template_name = 'project/reporting/this_month.html'
|
||||
|
||||
def get_context_data(self, request, **kwargs):
|
||||
today = datetime.datetime.today()
|
||||
day_start, day_end = calendar.monthrange(today.year, today.month)
|
||||
begin = "%4d-%02d-01T00:00:00" % (today.year, today.month)
|
||||
end = "%4d-%02d-%02dT23:59:59" % (today.year, today.month, day_end)
|
||||
|
||||
form = self.get_form()
|
||||
|
||||
if form.is_valid():
|
||||
# set values to be from datepicker form
|
||||
start = form.cleaned_data['start']
|
||||
end = form.cleaned_data['end']
|
||||
begin = "%4d-%02d-%02dT00:00:00" % (start.year,
|
||||
start.month, start.day)
|
||||
end = "%4d-%02d-%02dT23:59:59" % (end.year, end.month, end.day)
|
||||
|
||||
if end < begin:
|
||||
messages.error(
|
||||
self.request,
|
||||
_("Invalid time period. The end date should be "
|
||||
"more recent than the start date."
|
||||
" Setting the end as today."))
|
||||
|
||||
end = "%4d-%02d-%02dT23:59:59" % (today.year,
|
||||
today.month, day_end)
|
||||
|
||||
elif start > today.date():
|
||||
messages.error(
|
||||
self.request,
|
||||
_("Invalid time period. You are requesting "
|
||||
"data from the future which may not exist."))
|
||||
|
||||
elif form.is_bound:
|
||||
messages.error(
|
||||
self.request, _(
|
||||
"Invalid date format: Using this month as default.")
|
||||
)
|
||||
|
||||
begin = "%4d-%02d-%02dT00:00:00" % (today.year,
|
||||
today.month, day_start)
|
||||
end = "%4d-%02d-%02dT23:59:59" % (today.year, today.month, day_end)
|
||||
|
||||
else: # set default date values (before form is filled in)
|
||||
begin = "%4d-%02d-%02dT00:00:00" % (today.year,
|
||||
today.month, day_start)
|
||||
end = "%4d-%02d-%02dT23:59:59" % (today.year, today.month, day_end)
|
||||
|
||||
client = api.cloudkittyclient(request)
|
||||
data = client.storage.get_dataframes(
|
||||
begin=begin, end=end, tenant_id=request.user.tenant_id)
|
||||
parsed_data = _do_this_month(data)
|
||||
return {'repartition_data': parsed_data}
|
||||
return {'repartition_data': parsed_data, 'form': form}
|
||||
|
||||
@property
|
||||
def today(self):
|
||||
return timezone.now()
|
||||
|
||||
@property
|
||||
def first_day(self):
|
||||
days_range = settings.OVERVIEW_DAYS_RANGE
|
||||
if days_range:
|
||||
return self.today.date() - datetime.timedelta(days=days_range)
|
||||
return datetime.date(self.today.year, self.today.month, 1)
|
||||
|
||||
def init_form(self):
|
||||
self.start = self.first_day
|
||||
self.end = self.today.date()
|
||||
|
||||
return self.start, self.end
|
||||
|
||||
def get_form(self):
|
||||
if not hasattr(self, "form"):
|
||||
req = self.request
|
||||
start = req.GET.get('start', req.session.get('usage_start'))
|
||||
end = req.GET.get('end', req.session.get('usage_end'))
|
||||
if start and end:
|
||||
# bound form
|
||||
self.form = forms.DateForm({'start': start, 'end': end})
|
||||
|
||||
else:
|
||||
# non-bound form
|
||||
init = self.init_form()
|
||||
start = init[0].isoformat()
|
||||
end = init[1].isoformat()
|
||||
self.form = forms.DateForm(
|
||||
initial={'start': start, 'end': end})
|
||||
req.session['usage_start'] = start
|
||||
req.session['usage_end'] = end
|
||||
return self.form
|
||||
|
||||
|
||||
class ReportingTabs(tabs.TabGroup):
|
||||
slug = "reporting_tabs"
|
||||
tabs = (CostRepartitionTab, )
|
||||
tabs = (CostRepartitionTab,)
|
||||
sticky = True
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
# under the License.
|
||||
#
|
||||
|
||||
AUTO_DISCOVER_STATIC_FILES = True
|
||||
|
||||
PANEL_GROUP = 'rating'
|
||||
PANEL_DASHBOARD = 'project'
|
||||
PANEL = 'reporting'
|
||||
|
||||
75
cloudkittydashboard/static/cloudkitty/css/datepicker.css
Normal file
75
cloudkittydashboard/static/cloudkitty/css/datepicker.css
Normal file
@@ -0,0 +1,75 @@
|
||||
.form-inline {
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.form-inline h4 {
|
||||
text-align: left;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.form-inline .datepicker,
|
||||
.form-inline .datepicker-delimiter,
|
||||
.form-inline .btn {
|
||||
margin: 0 10px 0 0;
|
||||
}
|
||||
|
||||
.controls-container {
|
||||
text-align: left;
|
||||
margin: 10px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dropbtn,
|
||||
.arrow-btn {
|
||||
background-color: #EEEEEE;
|
||||
color: #6E6E6E;
|
||||
padding: 5px 10px;
|
||||
font-size: 16px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
background-color: #f1f1f1;
|
||||
min-width: 160px;
|
||||
box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dropdown-content button {
|
||||
color: black;
|
||||
background-color: #f1f1f1;
|
||||
border: none;
|
||||
padding: 12px 16px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.dropdown-content button:hover {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
.dropdown:hover .dropdown-content {
|
||||
display: block;
|
||||
}
|
||||
23
cloudkittydashboard/static/cloudkitty/css/this_month.css
Normal file
23
cloudkittydashboard/static/cloudkitty/css/this_month.css
Normal file
@@ -0,0 +1,23 @@
|
||||
|
||||
div.tooltip-donut {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
padding: .5rem;
|
||||
background: #FFFFFF;
|
||||
color: #313639;
|
||||
border: 1px solid #313639;
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
/* Styling for disabled legend items */
|
||||
.legend rect.disabled {
|
||||
opacity: 0.3;
|
||||
stroke-dasharray: 3,3;
|
||||
}
|
||||
|
||||
.legend rect.disabled + text {
|
||||
opacity: 0.5;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
180
cloudkittydashboard/static/cloudkitty/js/datepicker.js
Normal file
180
cloudkittydashboard/static/cloudkitty/js/datepicker.js
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* CloudKitty Datepicker functionality
|
||||
* Handles date range selection with preset ranges and navigation
|
||||
*/
|
||||
|
||||
function initDatepicker(options) {
|
||||
const {
|
||||
formId,
|
||||
startFieldName,
|
||||
endFieldName
|
||||
} = options;
|
||||
|
||||
const $form = $('#' + formId);
|
||||
const $startInput = $('[name="' + startFieldName + '"]');
|
||||
const $endInput = $('[name="' + endFieldName + '"]');
|
||||
|
||||
let lastClicked = sessionStorage.getItem('datepicker_lastClicked') || 'week';
|
||||
|
||||
// Utility functions
|
||||
const formatDate = (date) => {
|
||||
return date.getFullYear() + '-' +
|
||||
String(date.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(date.getDate()).padStart(2, '0');
|
||||
};
|
||||
|
||||
const disableButtons = () => {
|
||||
$('.controls-container button').prop('disabled', true);
|
||||
};
|
||||
|
||||
const submitForm = (startDate, endDate, periodType) => {
|
||||
$startInput.val(formatDate(startDate));
|
||||
$endInput.val(formatDate(endDate));
|
||||
lastClicked = periodType;
|
||||
sessionStorage.setItem('datepicker_lastClicked', lastClicked);
|
||||
$form.submit();
|
||||
};
|
||||
|
||||
// Date calculation functions
|
||||
const dateCalculators = {
|
||||
day: {
|
||||
current: () => {
|
||||
const date = new Date();
|
||||
return { start: new Date(date), end: new Date(date) };
|
||||
},
|
||||
yesterday: () => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - 1);
|
||||
return { start: new Date(date), end: new Date(date) };
|
||||
}
|
||||
},
|
||||
week: {
|
||||
current: () => {
|
||||
const start = new Date();
|
||||
start.setDate(start.getDate() - start.getDay() + 1);
|
||||
const end = new Date(start);
|
||||
end.setDate(start.getDate() + 6);
|
||||
return { start, end };
|
||||
},
|
||||
last: () => {
|
||||
const today = new Date();
|
||||
const currentWeekStart = new Date(today);
|
||||
currentWeekStart.setDate(today.getDate() - today.getDay() + 1);
|
||||
|
||||
const start = new Date(currentWeekStart);
|
||||
start.setDate(currentWeekStart.getDate() - 7);
|
||||
|
||||
const end = new Date(start);
|
||||
end.setDate(start.getDate() + 6);
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
},
|
||||
month: {
|
||||
current: () => {
|
||||
const today = new Date();
|
||||
const start = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const end = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
return { start, end };
|
||||
},
|
||||
last: (amount = 1) => {
|
||||
const end = new Date();
|
||||
end.setDate(0);
|
||||
const start = new Date();
|
||||
start.setMonth(end.getMonth() - (amount - 1), 1);
|
||||
return { start, end };
|
||||
}
|
||||
},
|
||||
year: {
|
||||
current: () => {
|
||||
const year = new Date().getFullYear();
|
||||
return {
|
||||
start: new Date(year, 0, 1),
|
||||
end: new Date(year, 11, 31)
|
||||
};
|
||||
},
|
||||
last: () => {
|
||||
const year = new Date().getFullYear() - 1;
|
||||
return {
|
||||
start: new Date(year, 0, 1),
|
||||
end: new Date(year, 11, 31)
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Navigation functions
|
||||
const navigate = (direction) => {
|
||||
if (!$startInput.val() || !$endInput.val()) return;
|
||||
|
||||
const currentStart = new Date($startInput.val());
|
||||
const currentEnd = new Date($endInput.val());
|
||||
const multiplier = direction === 'next' ? 1 : -1;
|
||||
|
||||
const navigators = {
|
||||
day: () => {
|
||||
currentStart.setDate(currentStart.getDate() + multiplier);
|
||||
currentEnd.setDate(currentEnd.getDate() + multiplier);
|
||||
},
|
||||
week: () => {
|
||||
currentStart.setDate(currentStart.getDate() + (7 * multiplier));
|
||||
currentEnd.setDate(currentEnd.getDate() + (7 * multiplier));
|
||||
},
|
||||
month: () => {
|
||||
if (direction === 'next') {
|
||||
currentStart.setMonth(currentStart.getMonth() + 1, 1);
|
||||
currentEnd.setMonth(currentEnd.getMonth() + 2, 0);
|
||||
} else {
|
||||
currentStart.setMonth(currentStart.getMonth() - 1, 1);
|
||||
currentEnd.setMonth(currentEnd.getMonth(), 0);
|
||||
}
|
||||
},
|
||||
last3Month: () => {
|
||||
if (direction === 'next') {
|
||||
currentStart.setMonth(currentStart.getMonth() + 3, 1);
|
||||
currentEnd.setMonth(currentEnd.getMonth() + 4, 0);
|
||||
} else {
|
||||
currentStart.setMonth(currentStart.getMonth() - 3, 1);
|
||||
currentEnd.setMonth(currentEnd.getMonth() - 2, 0);
|
||||
}
|
||||
},
|
||||
last6Month: () => {
|
||||
if (direction === 'next') {
|
||||
currentStart.setMonth(currentStart.getMonth() + 6, 1);
|
||||
currentEnd.setMonth(currentEnd.getMonth() + 7, 0);
|
||||
} else {
|
||||
currentStart.setMonth(currentStart.getMonth() - 6, 1);
|
||||
currentEnd.setMonth(currentEnd.getMonth() - 5, 0);
|
||||
}
|
||||
},
|
||||
year: () => {
|
||||
currentStart.setFullYear(currentStart.getFullYear() + multiplier);
|
||||
currentEnd.setFullYear(currentEnd.getFullYear() + multiplier);
|
||||
}
|
||||
};
|
||||
|
||||
if (navigators[lastClicked]) {
|
||||
navigators[lastClicked]();
|
||||
submitForm(currentStart, currentEnd, lastClicked);
|
||||
}
|
||||
};
|
||||
|
||||
// Event handlers
|
||||
$('.dropdown-content button[data-period]').on('click', function () {
|
||||
disableButtons();
|
||||
|
||||
const period = $(this).data('period');
|
||||
const view = $(this).data('view');
|
||||
const amount = $(this).data('amount') || 1;
|
||||
|
||||
const { start, end } = dateCalculators[period][view](amount);
|
||||
const periodType = amount > 1 ? `last${amount}Month` : period;
|
||||
|
||||
submitForm(start, end, periodType);
|
||||
});
|
||||
|
||||
$('.arrow-btn').on('click', function () {
|
||||
disableButtons();
|
||||
navigate($(this).data('direction'));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user