mirror of
https://github.com/frappe/books.git
synced 2024-12-23 11:29:03 +00:00
Merge branch 'master' of https://github.com/frappe/books
This commit is contained in:
commit
2ac4049cc8
136
src/components/Charts/DonutChart.vue
Normal file
136
src/components/Charts/DonutChart.vue
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<svg version="1.1" viewBox="0 0 100 100" @mouseleave="active = null">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="donut-hole">
|
||||||
|
<circle
|
||||||
|
:cx="cx"
|
||||||
|
:cy="cy"
|
||||||
|
:r="radius + thickness / 2"
|
||||||
|
fill="black"
|
||||||
|
stroke-width="0"
|
||||||
|
/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<circle
|
||||||
|
v-if="sectors.length === 1 || sectors.length === 0"
|
||||||
|
clip-path="url(#donut-hole)"
|
||||||
|
:cx="cx"
|
||||||
|
:cy="cy"
|
||||||
|
:r="radius"
|
||||||
|
@mouseover="active = sectors.length === 1 ? 0 : null"
|
||||||
|
:stroke-width="
|
||||||
|
thickness + (active === 0 || externalActive === 0 ? 4 : 0)
|
||||||
|
"
|
||||||
|
:stroke="(sectors[0] && sectors[0].color) || '#f4f4f6'"
|
||||||
|
:class="sectors.length >= 1 ? 'sector' : ''"
|
||||||
|
:style="{ transformOrigin: `${cx}px ${cy}px` }"
|
||||||
|
fill="transparent"
|
||||||
|
/>
|
||||||
|
<template v-if="sectors.length > 1">
|
||||||
|
<path
|
||||||
|
clip-path="url(#donut-hole)"
|
||||||
|
v-for="([theta, start_], i) in sectorsToStarts()"
|
||||||
|
:key="i"
|
||||||
|
:d="getArcPath(cx, cy, radius, start_, theta)"
|
||||||
|
:stroke="sectors[i].color"
|
||||||
|
:stroke-width="
|
||||||
|
thickness + (active === i || externalActive === i ? 4 : 0)
|
||||||
|
"
|
||||||
|
:style="{ transformOrigin: `${cx}px ${cy}px` }"
|
||||||
|
class="sector"
|
||||||
|
fill="transparent"
|
||||||
|
@mouseover="active = i"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</svg>
|
||||||
|
<div class="relative" style="top: -50%">
|
||||||
|
<div class="text-base text-center font-semibold grid justify-center">
|
||||||
|
<p class="text-xs text-gray-600 w-32">
|
||||||
|
{{
|
||||||
|
active !== null || externalActive !== null
|
||||||
|
? sectors[active !== null ? active : externalActive].label
|
||||||
|
: totalLabel
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p class="w-32">
|
||||||
|
{{
|
||||||
|
valueFormatter(
|
||||||
|
active !== null || externalActive !== null
|
||||||
|
? sectors[active !== null ? active : externalActive].value
|
||||||
|
: getTotalValue(),
|
||||||
|
'Currency'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
sectors: {
|
||||||
|
default: () => [],
|
||||||
|
type: Array,
|
||||||
|
},
|
||||||
|
totalLabel: { default: 'Total', type: String },
|
||||||
|
radius: { default: 36, type: Number },
|
||||||
|
thickness: { default: 10, type: Number },
|
||||||
|
externalActive: { default: null, type: Number },
|
||||||
|
valueFormatter: { default: (v) => v.toString(), Function },
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
cx: 50,
|
||||||
|
cy: 50,
|
||||||
|
width: 8,
|
||||||
|
active: null,
|
||||||
|
start: Math.PI,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getTotalValue() {
|
||||||
|
return this.sectors.map(({ value }) => value).reduce((a, b) => a + b, 0);
|
||||||
|
},
|
||||||
|
sectorsToRadians() {
|
||||||
|
const totalValue = this.getTotalValue();
|
||||||
|
return this.sectors.map(
|
||||||
|
({ value }) => (2 * Math.PI * value) / totalValue
|
||||||
|
);
|
||||||
|
},
|
||||||
|
sectorsToStarts() {
|
||||||
|
const theta = this.sectorsToRadians();
|
||||||
|
const starts = [...theta];
|
||||||
|
|
||||||
|
starts.forEach((e, i) => {
|
||||||
|
starts[i] += starts[i - 1] ?? 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
starts.unshift(0);
|
||||||
|
starts.pop();
|
||||||
|
|
||||||
|
return theta.map((t, i) => [t, starts[i]]);
|
||||||
|
},
|
||||||
|
getArcPath(...args) {
|
||||||
|
let [cx, cy, r, start, theta] = args.map(parseFloat);
|
||||||
|
start += parseFloat(this.start);
|
||||||
|
const startX = cx + r * Math.cos(start);
|
||||||
|
const startY = cy + r * Math.sin(start);
|
||||||
|
const endX = cx + r * Math.cos(start + theta);
|
||||||
|
const endY = cy + r * Math.sin(start + theta);
|
||||||
|
const largeArcFlag = theta > Math.PI ? 1 : 0;
|
||||||
|
const sweepFlag = 1;
|
||||||
|
|
||||||
|
return `M ${startX} ${startY} A ${r} ${r} 0 ${largeArcFlag} ${sweepFlag} ${endX} ${endY}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sector {
|
||||||
|
transition: 100ms stroke-width ease-out;
|
||||||
|
}
|
||||||
|
</style>
|
@ -12,32 +12,29 @@
|
|||||||
<div class="w-1/2">
|
<div class="w-1/2">
|
||||||
<div
|
<div
|
||||||
class="mt-5 flex justify-between items-center text-sm"
|
class="mt-5 flex justify-between items-center text-sm"
|
||||||
v-for="d in expenses"
|
v-for="(d, i) in expenses"
|
||||||
:key="d.name"
|
:key="d.name"
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div
|
||||||
|
class="flex items-center"
|
||||||
|
@mouseover="active = i"
|
||||||
|
@mouseleave="active = null"
|
||||||
|
>
|
||||||
<div class="w-3 h-3 rounded-sm" :class="d.class"></div>
|
<div class="w-3 h-3 rounded-sm" :class="d.class"></div>
|
||||||
<div class="ml-3">{{ d.account }}</div>
|
<div class="ml-3">{{ d.account }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>{{ frappe.format(d.total, 'Currency') }}</div>
|
<div>{{ frappe.format(d.total, 'Currency') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2">
|
<DonutChart
|
||||||
<div class="chart-wrapper" ref="top-expenses"></div>
|
class="w-1/2"
|
||||||
<div
|
:external-active="active"
|
||||||
class="absolute text-base text-center font-semibold"
|
:sectors="sectors"
|
||||||
style="top: 4rem; left: 75%; transform: translateX(-50%)"
|
:value-formatter="(value) => frappe.format(value, 'Currency')"
|
||||||
>
|
:total-label="_('Total Spending')"
|
||||||
<div>
|
/>
|
||||||
{{ frappe.format(totalExpense, 'Currency') }}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-gray-600">
|
|
||||||
{{ _('Total Spending') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="totalExpense === 0" class="flex-1 w-full h-full flex-center">
|
<div v-if="expenses.length === 0" class="flex-1 w-full h-full flex-center">
|
||||||
<span class="text-base text-gray-600">
|
<span class="text-base text-gray-600">
|
||||||
{{ _('No transactions yet') }}
|
{{ _('No transactions yet') }}
|
||||||
</span>
|
</span>
|
||||||
@ -47,24 +44,33 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import frappe from 'frappejs';
|
import frappe from 'frappejs';
|
||||||
import { Chart } from 'frappe-charts';
|
|
||||||
import theme from '@/theme';
|
import theme from '@/theme';
|
||||||
import PeriodSelector from './PeriodSelector';
|
import PeriodSelector from './PeriodSelector';
|
||||||
import SectionHeader from './SectionHeader';
|
import SectionHeader from './SectionHeader';
|
||||||
import { getDatesAndPeriodicity } from './getDatesAndPeriodicity';
|
import { getDatesAndPeriodicity } from './getDatesAndPeriodicity';
|
||||||
|
import DonutChart from '../../components/Charts/DonutChart.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Expenses',
|
name: 'Expenses',
|
||||||
components: {
|
components: {
|
||||||
|
DonutChart,
|
||||||
PeriodSelector,
|
PeriodSelector,
|
||||||
SectionHeader,
|
SectionHeader,
|
||||||
},
|
},
|
||||||
data: () => ({
|
data: () => ({
|
||||||
period: 'This Year',
|
period: 'This Year',
|
||||||
expenses: [{ account: 'Test', total: 0 }],
|
active: null,
|
||||||
|
sectors: [
|
||||||
|
{
|
||||||
|
value: 1,
|
||||||
|
label: frappe._('No Entries'),
|
||||||
|
color: theme.backgroundColor.gray['100'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
expenses: [],
|
||||||
}),
|
}),
|
||||||
activated() {
|
mounted() {
|
||||||
this.render();
|
this.setData();
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
period: 'render',
|
period: 'render',
|
||||||
@ -78,16 +84,19 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async render() {
|
async setData() {
|
||||||
let { fromDate, toDate } = await getDatesAndPeriodicity(this.period);
|
const { fromDate, toDate } = await getDatesAndPeriodicity(this.period);
|
||||||
let expenseAccounts = frappe.db.knex
|
const expenseAccounts = frappe.db.knex
|
||||||
.select('name')
|
.select('name')
|
||||||
.from('Account')
|
.from('Account')
|
||||||
.where('rootType', 'Expense');
|
.where('rootType', 'Expense');
|
||||||
|
|
||||||
let topExpenses = await frappe.db.knex
|
let topExpenses = await frappe.db.knex
|
||||||
.select({
|
.select({
|
||||||
total: frappe.db.knex.raw('sum(cast(?? as real)) - sum(cast(?? as real))', ['debit', 'credit']),
|
total: frappe.db.knex.raw(
|
||||||
|
'sum(cast(?? as real)) - sum(cast(?? as real))',
|
||||||
|
['debit', 'credit']
|
||||||
|
),
|
||||||
})
|
})
|
||||||
.select('account')
|
.select('account')
|
||||||
.from('AccountingLedgerEntry')
|
.from('AccountingLedgerEntry')
|
||||||
@ -97,42 +106,27 @@ export default {
|
|||||||
.orderBy('total', 'desc')
|
.orderBy('total', 'desc')
|
||||||
.limit(5);
|
.limit(5);
|
||||||
|
|
||||||
let shades = [
|
const shades = [
|
||||||
{ class: 'bg-gray-800', hex: theme.backgroundColor.gray['800'] },
|
{ class: 'bg-gray-800', hex: theme.backgroundColor.gray['800'] },
|
||||||
{ class: 'bg-gray-600', hex: theme.backgroundColor.gray['600'] },
|
{ class: 'bg-gray-600', hex: theme.backgroundColor.gray['600'] },
|
||||||
{ class: 'bg-gray-400', hex: theme.backgroundColor.gray['400'] },
|
{ class: 'bg-gray-400', hex: theme.backgroundColor.gray['400'] },
|
||||||
|
{ class: 'bg-gray-300', hex: theme.backgroundColor.gray['300'] },
|
||||||
{ class: 'bg-gray-200', hex: theme.backgroundColor.gray['200'] },
|
{ class: 'bg-gray-200', hex: theme.backgroundColor.gray['200'] },
|
||||||
{ class: 'bg-gray-100', hex: theme.backgroundColor.gray['100'] },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
topExpenses = topExpenses.map((d, i) => {
|
topExpenses = topExpenses.map((d, i) => {
|
||||||
d.class = shades[i].class;
|
|
||||||
d.color = shades[i].hex;
|
d.color = shades[i].hex;
|
||||||
|
d.class = shades[i].class;
|
||||||
return d;
|
return d;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.expenses = topExpenses;
|
this.expenses = topExpenses;
|
||||||
|
this.sectors = topExpenses.map(({ account, color, total }) => ({
|
||||||
new Chart(this.$refs['top-expenses'], {
|
color,
|
||||||
type: 'donut',
|
label: account,
|
||||||
hoverRadio: 0.01,
|
value: total,
|
||||||
strokeWidth: 18,
|
}));
|
||||||
colors: topExpenses.map((d) => d.color),
|
|
||||||
data: {
|
|
||||||
labels: topExpenses.map((d) => d.account),
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
values: topExpenses.map((d) => d.total),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
.donut-chart {
|
|
||||||
transform: translate(40px, 20px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
Loading…
Reference in New Issue
Block a user