add: отчет о смещении затрат относительно плана

This commit is contained in:
mfnefd 2024-12-11 00:00:04 +04:00
parent 732514300b
commit c9852a5384
13 changed files with 352 additions and 26 deletions

View File

@ -16,17 +16,17 @@ services:
- ~/.vsdbg:/remote_debugger:rw
depends_on:
- database
dombudg:
image: dombudg
build:
context: front
dockerfile: ./Dockerfile
environment:
- VITE_API_URL=http://api:5125
ports:
- 80:80
depends_on:
- api
# dombudg:
# image: dombudg
# build:
# context: front
# dockerfile: ./Dockerfile
# environment:
# - VITE_API_URL=http://api:5125
# ports:
# - 80:80
# depends_on:
# - api
database:
image: postgres:14
environment:
@ -35,6 +35,8 @@ services:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- 5432:5432
volumes:
postgres_data:

View File

@ -23,11 +23,14 @@ declare module 'vue' {
ASpace: typeof import('ant-design-vue/es')['Space']
ASpin: typeof import('ant-design-vue/es')['Spin']
ATable: typeof import('ant-design-vue/es')['Table']
ATypographyText: typeof import('ant-design-vue/es')['TypographyText']
ATypographyTitle: typeof import('ant-design-vue/es')['TypographyTitle']
ChangeRecordManager: typeof import('./src/components/support/ChangeRecordManager.vue')['default']
Groups: typeof import('./src/components/pages/Groups.vue')['default']
Header: typeof import('./src/components/main/Header.vue')['default']
Home: typeof import('./src/components/pages/Home.vue')['default']
Login: typeof import('./src/components/pages/Login.vue')['default']
OffsetReport: typeof import('./src/components/pages/OffsetReport.vue')['default']
PlanManager: typeof import('./src/components/support/PlanManager.vue')['default']
Plans: typeof import('./src/components/pages/Plans.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']

174
front/package-lock.json generated
View File

@ -13,7 +13,8 @@
"dayjs": "^1.11.13",
"pinia": "^2.2.8",
"vue": "^3.5.12",
"vue-router": "^4.5.0"
"vue-router": "^4.5.0",
"vue3-charts": "^1.1.33"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.4",
@ -1443,6 +1444,136 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-axis": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-hierarchy": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
@ -1726,6 +1857,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
@ -2308,6 +2448,16 @@
],
"license": "MIT"
},
"node_modules/ramda": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.28.0.tgz",
"integrity": "sha512-9QnLuG/kPVgWvMQ4aODhsBUFKOUmnbUnsSXACv+NCQZcHbeb+v8Lodp8OVxtRULN1/xOyYLLaL6npE6dMq5QTA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ramda"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@ -2895,6 +3045,28 @@
"vue": "^3.0.0"
}
},
"node_modules/vue3-charts": {
"version": "1.1.33",
"resolved": "https://registry.npmjs.org/vue3-charts/-/vue3-charts-1.1.33.tgz",
"integrity": "sha512-gu2N/oORcAWLo3orfoKz5CRohZdmxQP7k2SZ8cgRsD9hFmYpekesE41EUPdGuZ5Y9gAo2LbGYW7fmIGbbPezDg==",
"dependencies": {
"d3-array": "^3.2.0",
"d3-axis": "^3.0.0",
"d3-format": "^3.1.0",
"d3-hierarchy": "^3.1.2",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-selection": "^3.0.0",
"d3-shape": "^3.1.0",
"ramda": "^0.28.0"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"vue": ">=3.0.0"
}
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",

View File

@ -15,7 +15,8 @@
"dayjs": "^1.11.13",
"pinia": "^2.2.8",
"vue": "^3.5.12",
"vue-router": "^4.5.0"
"vue-router": "^4.5.0",
"vue3-charts": "^1.1.33"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.4",

View File

@ -3,6 +3,7 @@ import { inject } from 'vue';
import { useUserStore } from '../../store';
import { AuthService } from '../../core/services/auth-service';
import router from '../../router';
import { HomeOutlined, BlockOutlined } from '@ant-design/icons-vue';
const store = useUserStore();
const authService = inject(AuthService.name) as AuthService;
@ -19,8 +20,8 @@ function logout() {
<div class="base-nav">
<div>ДомБюдж</div>
<nav>
<RouterLink :to="{ name: 'home' }">Главная</RouterLink>
<RouterLink :to="{ name: 'groups' }">Группы расходов</RouterLink>
<RouterLink :to="{ name: 'home' }"><HomeOutlined /> Главная</RouterLink>
<RouterLink :to="{ name: 'groups' }"><BlockOutlined /> Группы расходов</RouterLink>
</nav>
</div>
<div v-if="!store.user.id">

View File

@ -3,7 +3,7 @@ import { useAsyncState } from '@vueuse/core';
import { inject } from 'vue';
import { GroupService } from '../../core/services/group-service';
import SpendingGroupManager from '../support/SpendingGroupManager.vue';
import { DeleteOutlined } from '@ant-design/icons-vue';
import { DeleteOutlined, CalendarOutlined } from '@ant-design/icons-vue';
const groupService = inject(GroupService.name) as GroupService;
@ -43,12 +43,15 @@ const onDelete = (key: string) => {
<template>
<div class="base-page">
<h1>Группы расходов</h1>
<a-typography-title>Группы расходов</a-typography-title>
<SpendingGroupManager :refreshData="refreshData" />
<a-table :dataSource="state" :columns="columns" v-if="isReady">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'plans'">
<RouterLink :to="{ name: 'plans', params: { groupId: record.id } }" >Планы</RouterLink>
<RouterLink :to="{ name: 'plans', params: { groupId: record.id } }"
>
<CalendarOutlined /> Планы
</RouterLink>
</template>
<template v-else-if="column.dataIndex === 'operation'">
<a-popconfirm

View File

@ -48,7 +48,7 @@ const onDelete = (key: string) => {
<template>
<div class="base-page">
<h1>История изменений баланса</h1>
<a-typography-title>История изменений баланса</a-typography-title>
<ChangeRecordMenu :refreshData="refreshData" />
<a-table :dataSource="state" :columns="columns" v-if="isReady" >
<template #bodyCell="{ column, record }">

View File

@ -0,0 +1,90 @@
<style scoped>
</style>
<template>
<div class="base-page">
<a-typography-title>Отчет о смещении затрат группы относительно плана</a-typography-title>
<div v-if="isReady">
<a-typography-title :level="4">
Для {{ reportData.groupName }} с {{ reportData.startAt }} по {{ reportData.endAt }}. План: {{ reportData.planSum }}
</a-typography-title>
<Responsive class="w-full">
<template #main="{ width }">
<Chart
direction="circular"
:data="reportData.data"
:size="{ width, height: 400 }"
:margin="{
left: Math.round((width - 360)/2),
top: 20,
right: 0,
bottom: 20
}"
:axis="{
primary: {
domain: ['dataMin', 'dataMax'],
type: 'band',
hide: true
},
secondary: {
domain: ['dataMin', 'dataMax'],
type: 'linear',
hide: true
}
}"
:config="{ controlHover: false }"
>
<template #layers>
<Pie
:dataKeys="['label', 'data']"
:pie-style="{ innerRadius: 100, padAngle: 0.05 }" />
</template>
<template #widgets>
<Tooltip
:config="{
label: { label: 'Дата' },
data: { label: 'Затраты' },
}"
hideLine
/>
</template>
</Chart>
</template>
</Responsive>
<div v-if="reportData.total > reportData.planSum">
<a-typography-text type="danger" strong>
<WarningOutlined /> Затраты вышли за указанный план! Итоговая разница: {{ reportData.total - reportData.planSum }}
</a-typography-text>
</div>
<div v-else>
<a-typography-text type="success">
<CheckOutlined /> Затраты не вышли за указанный план! Итоговая разница: {{ reportData.planSum - reportData.total }}
</a-typography-text>
</div>
<br/>
<a-table
:dataSource="reportData.data"
:columns="[
{ title: 'Дата', dataIndex: 'label', key: 'label' },
{ title: 'Сумма', dataIndex: 'data', key: 'data' },
]"
/>
</div>
<div v-else>
<a-spin size="large" />
</div>
</div>
</template>
<script setup lang="ts">
import { inject } from 'vue';
import { OffsetFromPlanReportData, ReportService } from '../../core/services/report-service';
import { useRoute } from 'vue-router';
import { useAsyncState } from '@vueuse/core';
import { Chart, Tooltip, Pie, Responsive } from 'vue3-charts';
import { CheckOutlined, WarningOutlined } from '@ant-design/icons-vue';
let reportService = inject(ReportService.name) as ReportService;
let planId = useRoute().params.planId as string;
const { state : reportData, isReady } = useAsyncState(() => reportService.getOffsetFromPlanData(planId), {} as OffsetFromPlanReportData);
</script>

View File

@ -4,7 +4,7 @@ import { inject } from 'vue';
import { PlanService } from '../../core/services/plans-service';
import { useRoute } from 'vue-router';
import PlanManager from '../support/PlanManager.vue';
import { DeleteOutlined } from '@ant-design/icons-vue';
import { DeleteOutlined, PieChartOutlined } from '@ant-design/icons-vue';
const planService = inject(PlanService.name) as PlanService;
const groupId = useRoute().params.groupId as string;
@ -53,7 +53,7 @@ const onDelete = (key: string) => {
<template>
<div class="base-page">
<h1>Планы группы</h1>
<a-typography-title>Планы группы</a-typography-title>
<PlanManager :groupId="groupId" :refreshData="refreshData"/>
<a-table :dataSource="state" :columns="columns" v-if="isReady">
<template #bodyCell="{ column, record }">
@ -65,6 +65,15 @@ const onDelete = (key: string) => {
>
<a><DeleteOutlined /> Удалить</a>
</a-popconfirm>
<br/>
<router-link
:to="{
name: 'offsetReport',
params: { planId: record.id }
}"
>
<PieChartOutlined /> Отчет
</router-link>
</template>
</template>
</a-table>

View File

@ -451,7 +451,7 @@ export class Api<SecurityDataType = unknown> extends HttpClient<SecurityDataType
* @request GET:/api/User
* @response `200` `UserViewModel` Success
*/
userList = (
userGet = (
query?: {
/** @format uuid */
Id?: string;

View File

@ -55,7 +55,7 @@ export enum ContentType {
}
export class HttpClient<SecurityDataType = unknown> {
public baseUrl: string = "http://172.29.224.204:5215";
public baseUrl: string = "http://172.29.224.204:5125";
private securityData: SecurityDataType | null = null;
private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"];
private abortControllers = new Map<CancelToken, AbortController>();

View File

@ -2,16 +2,32 @@ import { Dayjs } from "dayjs";
import { Api } from "../api/Api";
import { SpendingGroupViewModel } from "../api/data-contracts";
export type ReportData = {
label: string;
data: number | string;
};
export interface OffsetFromPlanReportData {
groupName: string;
startAt: string;
endAt: string;
planSum: number;
total: number,
data: ReportData[];
}
export class ReportService {
private readonly _api: Api
constructor(api: Api) {
this._api = api;
}
public async getOffsetFromPlanData(Id: string): Promise<SpendingGroupViewModel> {
public async getOffsetFromPlanData(Id: string): Promise<OffsetFromPlanReportData> {
console.log(Id);
let res = await this._api.reportPlanDetail(Id);
console.log(res);
return res.data;
let reportData = getOffsetFromPlanData(res.data);
if (!reportData) throw new Error("Cannot get report data");
return reportData;
}
public async getPeriodData(from: Dayjs, to: Dayjs): Promise<SpendingGroupViewModel[]> {
@ -22,4 +38,28 @@ export class ReportService {
console.log(res);
return res.data;
}
}
function getOffsetFromPlanData(group: SpendingGroupViewModel): OffsetFromPlanReportData | null {
if (!group.changeRecords || !group.spendingPlans) return null;
let reportData : ReportData[] = [];
group.changeRecords.forEach((cr) => {
if (!reportData.find(x => x.label == cr.changedAt!)) {
reportData.push({
label: cr.changedAt!,
data: Math.abs(cr.sum!)
})
}
else {
(reportData.find(x => x.label == cr.changedAt)!.data as number) += Math.abs(cr.sum!);
}
})
return {
groupName: group.name!,
startAt: group.spendingPlans[0].startAt!,
endAt: group.spendingPlans[0].endAt!,
planSum: group.spendingPlans[0].sum!,
total: reportData.map(x => x.data as number).reduce((a, b) => a + b, 0),
data: reportData
};
}

View File

@ -30,7 +30,12 @@ const router = createRouter({
path: '/plans/:groupId',
name: 'plans',
component: () => import('./components/pages/Plans.vue'),
}
},
{
path: '/plans/report/offset/:planId',
name: 'offsetReport',
component: () => import('./components/pages/OffsetReport.vue'),
},
],
});