Compare commits

..

No commits in common. "jeyerp" and "main" have entirely different histories.
jeyerp ... main

63 changed files with 3267 additions and 12 deletions

21
.editorconfig Normal file
View File

@ -0,0 +1,21 @@
# Root editor config file
root = true
# Common settings
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
# python, js indentation settings
[{*.py,*.js,*.vue,*.css,*.scss,*.html}]
indent_style = tab
indent_size = 4
max_line_length = 99
# JSON files - mostly doctype schema files
[{*.json}]
insert_final_newline = false
indent_style = space
indent_size = 1

125
.eslintrc Normal file
View File

@ -0,0 +1,125 @@
{
"env": {
"browser": true,
"node": true,
"es2022": true
},
"parserOptions": {
"sourceType": "module"
},
"extends": "eslint:recommended",
"rules": {
"indent": "off",
"brace-style": "off",
"no-mixed-spaces-and-tabs": "off",
"no-useless-escape": "off",
"space-unary-ops": ["error", { "words": true }],
"linebreak-style": "off",
"quotes": ["off"],
"semi": "off",
"camelcase": "off",
"no-unused-vars": "off",
"no-console": ["warn"],
"no-extra-boolean-cast": ["off"],
"no-control-regex": ["off"],
},
"root": true,
"globals": {
"frappe": true,
"Vue": true,
"SetVueGlobals": true,
"__": true,
"repl": true,
"Class": true,
"locals": true,
"cint": true,
"cstr": true,
"cur_frm": true,
"cur_dialog": true,
"cur_page": true,
"cur_list": true,
"cur_tree": true,
"msg_dialog": true,
"is_null": true,
"in_list": true,
"has_common": true,
"posthog": true,
"has_words": true,
"validate_email": true,
"open_web_template_values_editor": true,
"validate_name": true,
"validate_phone": true,
"validate_url": true,
"get_number_format": true,
"format_number": true,
"format_currency": true,
"comment_when": true,
"open_url_post": true,
"toTitle": true,
"lstrip": true,
"rstrip": true,
"strip": true,
"strip_html": true,
"replace_all": true,
"flt": true,
"precision": true,
"CREATE": true,
"AMEND": true,
"CANCEL": true,
"copy_dict": true,
"get_number_format_info": true,
"strip_number_groups": true,
"print_table": true,
"Layout": true,
"web_form_settings": true,
"$c": true,
"$a": true,
"$i": true,
"$bg": true,
"$y": true,
"$c_obj": true,
"refresh_many": true,
"refresh_field": true,
"toggle_field": true,
"get_field_obj": true,
"get_query_params": true,
"unhide_field": true,
"hide_field": true,
"set_field_options": true,
"getCookie": true,
"getCookies": true,
"get_url_arg": true,
"md5": true,
"$": true,
"jQuery": true,
"moment": true,
"hljs": true,
"Awesomplete": true,
"Sortable": true,
"gemoji": true,
"Showdown": true,
"Taggle": true,
"Gantt": true,
"Slick": true,
"Webcam": true,
"PhotoSwipe": true,
"PhotoSwipeUI_Default": true,
"io": true,
"JsBarcode": true,
"L": true,
"Chart": true,
"DataTable": true,
"Cypress": true,
"cy": true,
"it": true,
"describe": true,
"expect": true,
"context": true,
"before": true,
"beforeEach": true,
"after": true,
"qz": true,
"localforage": true,
"extend_cscript": true
}
}

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
__pycache__
*.pyc
*.pyo
.eggs
*.egg-info
dist
build
*.egg
.DS_Store
# Frontend build output
inspector/public/frontend/
frontend/node_modules/
frontend/yarn.lock
frontend/.vite/

69
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,69 @@
exclude: 'node_modules|.git'
default_stages: [pre-commit]
fail_fast: false
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
files: "inspector.*"
exclude: ".*json$|.*txt$|.*csv|.*md|.*svg"
- id: check-merge-conflict
- id: check-ast
- id: check-json
- id: check-toml
- id: check-yaml
- id: debug-statements
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.10
hooks:
- id: ruff
name: "Run ruff import sorter"
args: ["--select=I", "--fix"]
- id: ruff
name: "Run ruff linter"
- id: ruff-format
name: "Run ruff formatter"
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.7.1
hooks:
- id: prettier
types_or: [javascript, vue, scss]
# Ignore any files that might contain jinja / bundles
exclude: |
(?x)^(
inspector/public/dist/.*|
.*node_modules.*|
.*boilerplate.*|
inspector/templates/includes/.*|
inspector/public/js/lib/.*
)$
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v8.44.0
hooks:
- id: eslint
types_or: [javascript]
args: ['--quiet']
# Ignore any files that might contain jinja / bundles
exclude: |
(?x)^(
inspector/public/dist/.*|
cypress/.*|
.*node_modules.*|
.*boilerplate.*|
inspector/templates/includes/.*|
inspector/public/js/lib/.*
)$
ci:
autoupdate_schedule: weekly
skip: []
submodules: false

10
LICENSE
View File

@ -1,10 +0,0 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means.
In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org/>

View File

@ -1,3 +1,33 @@
# inspector
### Inspector
Jey ERP Inspector
Inspector - Tax Audit Dashboard
### Installation
You can install this app using the [bench](https://github.com/frappe/bench) CLI:
```bash
cd $PATH_TO_YOUR_BENCH
bench get-app $URL_OF_THIS_REPO --branch main
bench install-app inspector
```
### Contributing
This app uses `pre-commit` for code formatting and linting. Please [install pre-commit](https://pre-commit.com/#installation) and enable it for this repository:
```bash
cd apps/inspector
pre-commit install
```
Pre-commit is configured to use the following tools for checking and formatting your code:
- ruff
- eslint
- prettier
- pyupgrade
### License
mit

26
frontend/index.html Normal file
View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Inspector</title>
<link
href="https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;600;700;800&family=Source+Code+Pro:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body class="antialiased">
<div id="app"></div>
<div id="modals"></div>
<div id="popovers"></div>
<script>
window.csrf_token = "{{ csrf_token }}"
window.site_name = "{{ site_name }}"
if (!window.frappe) window.frappe = {}
frappe.boot = {{ boot }}
</script>
<script type="module" src="/src/main.js"></script>
</body>
</html>

22
frontend/package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "inspector-frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build --base=/assets/inspector/frontend/ && yarn copy-html-entry",
"copy-html-entry": "cp ../inspector/public/frontend/index.html ../inspector/www/inspector.html"
},
"dependencies": {
"@vitejs/plugin-vue": "^4.4.0",
"autoprefixer": "^10.4.19",
"feather-icons": "^4.29.1",
"frappe-ui": "0.1.105",
"postcss": "^8.4.5",
"tailwindcss": "^3.4.3",
"vite": "^5.4.10",
"vue": "^3.5.12",
"vue-router": "^4.3.2"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

9
frontend/src/App.vue Normal file
View File

@ -0,0 +1,9 @@
<template>
<AppLayout>
<router-view />
</AppLayout>
</template>
<script setup>
import AppLayout from "@/components/layout/AppLayout.vue"
</script>

View File

@ -0,0 +1,99 @@
<template>
<header
class="fixed top-0 left-0 right-0 z-[100] h-[var(--ins-header-h)] bg-white border-b border-[var(--border)] flex items-center justify-between px-6"
:style="{ paddingLeft: 'calc(var(--ins-sidebar-w) + 24px)' }"
>
<div class="flex items-center gap-7">
<!-- Brand clickable, goes to Pulse -->
<div
class="flex items-center gap-2.5 cursor-pointer hover:opacity-80 transition-opacity"
@click="$router.push('/pulse')"
>
<div
class="w-8 h-8 rounded-lg bg-gradient-to-br from-[var(--ins-indigo)] to-purple-600 flex items-center justify-center text-white text-xs font-extrabold"
>
JI
</div>
<div>
<div class="text-base font-extrabold tracking-tight">Inspector</div>
<div
class="text-[10px] font-semibold text-[var(--ins-text-tertiary)] uppercase tracking-wider"
>
Audit Dashboard
</div>
</div>
</div>
<!-- Nav Tabs -->
<nav
class="flex gap-0.5 bg-[var(--bg-base)] rounded-lg p-[3px]"
>
<button
v-for="tab in tabs"
:key="tab.route"
class="px-3.5 py-[7px] rounded-md text-xs font-semibold transition-all duration-150"
:class="
currentRoute === tab.route
? 'text-[var(--ins-indigo)] bg-white shadow-sm'
: 'text-[var(--ins-text-tertiary)] hover:text-[var(--ins-text-secondary)] hover:bg-black/[0.03]'
"
@click="$router.push(tab.route)"
>
{{ tab.label }}
</button>
</nav>
</div>
<div class="flex items-center gap-3">
<!-- Company selector -->
<select
class="px-3.5 py-1.5 pr-7 rounded-md bg-white border border-[var(--border)] font-manrope text-xs font-semibold text-[var(--ins-text-secondary)] cursor-pointer appearance-none"
style="
background-image: url(&quot;data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%2394a3b8' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E&quot;);
background-repeat: no-repeat;
background-position: right 10px center;
"
>
<option>Jey Soft MMC</option>
<option>Все компании</option>
</select>
<!-- Language badge -->
<div class="flex gap-px bg-[var(--bg-base)] rounded-md p-0.5">
<button
class="px-2.5 py-1 rounded text-[10px] font-bold bg-white text-[var(--ins-indigo)] shadow-sm"
>
RU
</button>
<button
class="px-2.5 py-1 rounded text-[10px] font-bold text-[var(--ins-text-tertiary)] cursor-not-allowed opacity-50"
disabled
>
AZ
</button>
<button
class="px-2.5 py-1 rounded text-[10px] font-bold text-[var(--ins-text-tertiary)] cursor-not-allowed opacity-50"
disabled
>
EN
</button>
</div>
</div>
</header>
</template>
<script setup>
import { computed } from "vue"
import { useRoute } from "vue-router"
const route = useRoute()
const currentRoute = computed(() => route.path)
const tabs = [
{ label: "Пульс", route: "/pulse" },
{ label: "Инспекция", route: "/inspection" },
{ label: "Виды налогов", route: "/tax-types" },
{ label: "Бухгалтерия", route: "/accounting" },
{ label: "Настройки", route: "/settings" },
]
</script>

View File

@ -0,0 +1,16 @@
<template>
<div class="min-h-screen bg-[var(--bg-page)]">
<AppHeader />
<MiniSidebar />
<main
class="ml-[var(--ins-sidebar-w)] mt-[var(--ins-header-h)] px-6 py-5 max-w-content mx-auto"
>
<slot />
</main>
</div>
</template>
<script setup>
import AppHeader from "./AppHeader.vue"
import MiniSidebar from "./MiniSidebar.vue"
</script>

View File

@ -0,0 +1,60 @@
<template>
<nav
class="fixed top-[var(--ins-header-h)] left-0 bottom-0 z-[90] w-[var(--ins-sidebar-w)] bg-white border-r border-[var(--border)] flex flex-col items-center pt-3.5 gap-1"
>
<div
v-for="item in items"
:key="item.route"
class="relative w-[38px] h-[38px] rounded-lg flex items-center justify-center cursor-pointer transition-all duration-150 group"
:class="
currentRoute === item.route
? 'bg-[var(--ins-indigo-dim)] text-[var(--ins-indigo)]'
: 'text-[var(--ins-text-tertiary)] hover:bg-[var(--bg-base)] hover:text-[var(--ins-text-secondary)]'
"
@click="$router.push(item.route)"
>
<div v-html="item.icon" />
<span
class="hidden group-hover:block absolute left-[46px] top-1/2 -translate-y-1/2 bg-[var(--ins-text-primary)] px-2.5 py-1 rounded-md text-[11px] font-semibold text-white whitespace-nowrap z-[999]"
>
{{ item.label }}
</span>
</div>
</nav>
</template>
<script setup>
import { computed } from "vue"
import { useRoute } from "vue-router"
const route = useRoute()
const currentRoute = computed(() => route.path)
const items = [
{
label: "Пульс",
route: "/pulse",
icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>',
},
{
label: "Инспекция",
route: "/inspection",
icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>',
},
{
label: "Налоги",
route: "/tax-types",
icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/></svg>',
},
{
label: "Бухгалтерия",
route: "/accounting",
icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 20h20M5 20V8l5 4V4l5 4v12"/><rect x="17" y="12" width="4" height="8"/></svg>',
},
{
label: "Настройки",
route: "/settings",
icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2z"/><circle cx="12" cy="12" r="3"/></svg>',
},
]
</script>

View File

@ -0,0 +1,56 @@
<template>
<div class="flex flex-col">
<div
v-for="(item, i) in items"
:key="i"
class="flex gap-3 py-2.5 relative"
>
<!-- Connecting line -->
<div
v-if="i < items.length - 1"
class="absolute left-[15px] top-[36px] bottom-[-2px] w-[2px] bg-[var(--border)]"
/>
<!-- Dot -->
<div
class="w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0 z-[1] border-2"
:class="dotClass(item.urgency)"
>
{{ item.icon }}
</div>
<!-- Content -->
<div class="flex-1">
<div class="text-[13px] font-bold">{{ item.title }}</div>
<div class="text-[11px] font-semibold text-[var(--ins-text-tertiary)] font-data">
{{ item.date }}
<span
class="text-[10px] font-bold px-[7px] py-[2px] rounded-[10px] ml-1.5"
:class="daysClass(item.urgency)"
>
{{ item.days <= 1 ? item.days + ' день!' : item.days + ' дней' }}
</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
items: {
type: Array,
default: () => [],
},
})
function dotClass(urgency) {
if (urgency === "urgent") return "border-red-500 bg-red-50 animate-[timelinePulse_2s_infinite]"
if (urgency === "soon") return "border-amber-500 bg-amber-50"
return "border-emerald-500 bg-emerald-50"
}
function daysClass(urgency) {
if (urgency === "urgent") return "bg-[var(--ins-negative-bg)] text-[var(--ins-negative)]"
if (urgency === "soon") return "bg-[var(--ins-warning-bg)] text-[var(--ins-warning)]"
return "bg-[var(--ins-positive-bg)] text-[var(--ins-positive)]"
}
</script>

View File

@ -0,0 +1,55 @@
<template>
<div>
<div v-for="item in items" :key="item.name" class="mb-2.5">
<div class="flex justify-between text-[11px] font-semibold mb-1">
<span class="text-[var(--ins-text-secondary)]">{{ item.name }}</span>
<span class="text-[var(--ins-text-tertiary)] font-data">
{{ item.filed }}/{{ item.total }}
</span>
</div>
<div
class="h-2 bg-[var(--bg-base)] rounded overflow-hidden flex"
>
<div
class="h-full transition-all duration-500"
:style="{ width: filedPct(item) + '%', background: 'var(--ins-positive)' }"
/>
<div
v-if="pendingPct(item) > 0"
class="h-full transition-all duration-500"
:style="{ width: pendingPct(item) + '%', background: 'var(--ins-warning)' }"
/>
<div
v-if="latePct(item) > 0"
class="h-full transition-all duration-500"
:style="{ width: latePct(item) + '%', background: 'var(--ins-negative)' }"
/>
</div>
</div>
<div class="mt-2.5 flex gap-3 text-[10px] font-bold">
<span class="text-[var(--ins-positive)]"> Сдано</span>
<span class="text-[var(--ins-warning)]"> Ожидает</span>
<span class="text-[var(--ins-negative)]"> Просрочено</span>
</div>
</div>
</template>
<script setup>
defineProps({
items: {
type: Array,
default: () => [],
// [{ name, filed, total, late }]
},
})
function filedPct(item) {
return (item.filed / item.total) * 100
}
function latePct(item) {
return ((item.late || 0) / item.total) * 100
}
function pendingPct(item) {
return ((item.total - item.filed - (item.late || 0)) / item.total) * 100
}
</script>

View File

@ -0,0 +1,81 @@
<template>
<div class="flex flex-col items-center justify-center py-5">
<div
class="relative w-[180px] h-[180px]"
:class="{ spinning: spinning }"
:style="spinning ? '' : 'animation: donutPulse 3s ease-in-out infinite, donutBreathe 4s ease-in-out infinite'"
>
<svg viewBox="0 0 180 180" class="w-[180px] h-[180px]" :style="spinning ? 'animation: ringSpin 1.5s linear infinite' : 'transform: rotate(-90deg)'">
<defs>
<linearGradient id="healthGrad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="var(--ins-negative)" />
<stop offset="40%" stop-color="var(--ins-warning)" />
<stop offset="100%" stop-color="var(--ins-positive)" />
</linearGradient>
</defs>
<circle
cx="90" cy="90" r="76"
fill="none"
stroke="var(--bg-base)"
stroke-width="14"
/>
<circle
cx="90" cy="90" r="76"
fill="none"
stroke="var(--ins-indigo)"
stroke-width="18"
opacity="0.1"
:stroke-dasharray="circumference"
:stroke-dashoffset="glowOffset"
style="filter: blur(4px)"
/>
<circle
cx="90" cy="90" r="76"
fill="none"
:stroke="spinning ? 'var(--ins-indigo)' : 'url(#healthGrad)'"
stroke-width="14"
stroke-linecap="round"
:stroke-dasharray="circumference"
:stroke-dashoffset="spinning ? 380 : fillOffset"
:style="spinning ? '' : 'transition: stroke-dashoffset 1s cubic-bezier(.22,1,.36,1)'"
/>
</svg>
<div class="absolute inset-0 flex flex-col items-center justify-center">
<div class="text-5xl font-extrabold tracking-tighter leading-none text-[var(--ins-text-primary)]">
{{ score }}
</div>
<div class="text-[10px] font-bold text-[var(--ins-text-tertiary)] uppercase tracking-widest mt-1">
Health Score
</div>
</div>
</div>
<button
class="mt-2.5 px-3.5 py-1 rounded-full text-xs font-bold cursor-pointer transition-all duration-150 hover:scale-105"
:class="
score >= 80
? 'bg-[var(--ins-positive-bg)] text-[var(--ins-positive)]'
: score >= 50
? 'bg-[var(--ins-warning-bg)] text-[var(--ins-warning)]'
: 'bg-[var(--ins-negative-bg)] text-[var(--ins-negative)]'
"
@click="$emit('attention')"
>
{{ score >= 80 ? '✓ Хорошо' : score >= 50 ? '⚠ Требует внимания' : '✕ Критично' }}
</button>
</div>
</template>
<script setup>
import { computed } from "vue"
const props = defineProps({
score: { type: Number, default: 75 },
spinning: { type: Boolean, default: false },
})
defineEmits(["attention"])
const circumference = 2 * Math.PI * 76 // ~478
const fillOffset = computed(() => circumference * (1 - props.score / 100))
const glowOffset = computed(() => circumference * (1 - props.score / 100))
</script>

View File

@ -0,0 +1,75 @@
<template>
<div
class="flex items-center gap-3.5 px-5 py-3.5 glass rounded-xl flex-1 cursor-pointer select-none transition-all duration-300 hover:translate-y-[-2px] hover:shadow-lg active:scale-[0.97] active:duration-[60ms]"
:class="{ 'pointer-events-none': skeleton }"
@click="$emit('click')"
>
<div
class="w-[44px] h-[44px] rounded-full flex items-center justify-center text-base relative flex-shrink-0"
:class="iconClass"
>
<span
class="absolute inset-[-3px] rounded-full border-2 opacity-20"
:class="ringClass"
/>
<span class="text-xs font-extrabold">{{ up ? '↑' : '↓' }}</span>
</div>
<div class="flex-1 min-w-0">
<div
class="text-2xl font-extrabold tracking-tight leading-none text-[var(--ins-text-primary)]"
:class="{ 'animate-shimmer text-transparent rounded': skeleton }"
:style="valueStyle"
v-html="value"
/>
<div
class="text-[10px] font-semibold text-[var(--ins-text-tertiary)] mt-[3px] flex items-center gap-1.5"
:class="{ 'animate-shimmer text-transparent rounded': skeleton }"
>
{{ label }}
<span
v-if="change && !skeleton"
class="text-[10px] font-bold px-1.5 py-[1px] rounded-[10px]"
:class="
up
? 'bg-[var(--ins-positive-bg)] text-[var(--ins-positive)]'
: 'bg-[var(--ins-negative-bg)] text-[var(--ins-negative)]'
"
>
{{ change }}
</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from "vue"
const props = defineProps({
label: String,
value: [String, Number],
change: String,
up: { type: Boolean, default: true },
color: { type: String, default: "green" },
skeleton: { type: Boolean, default: false },
valueStyle: { type: String, default: "" },
})
defineEmits(["click"])
const iconClass = computed(() => ({
"bg-emerald-500/[0.08] text-emerald-600": props.color === "green",
"bg-amber-500/[0.08] text-amber-600": props.color === "yellow",
"bg-blue-500/[0.08] text-blue-600": props.color === "blue",
"bg-red-500/[0.08] text-red-600": props.color === "red",
"bg-indigo-500/[0.08] text-indigo-600": props.color === "indigo",
}))
const ringClass = computed(() => ({
"border-emerald-600": props.color === "green",
"border-amber-600": props.color === "yellow",
"border-blue-600": props.color === "blue",
"border-red-600": props.color === "red",
"border-indigo-600": props.color === "indigo",
}))
</script>

View File

@ -0,0 +1,40 @@
<template>
<div>
<div
v-for="bar in bars"
:key="bar.name"
class="flex items-center gap-2 mb-2"
>
<div
class="text-[11px] font-semibold text-[var(--ins-text-secondary)] min-w-[72px] text-right"
>
{{ bar.name }}
</div>
<div class="flex-1 h-[22px] bg-[var(--bg-base)] rounded overflow-hidden">
<div
class="h-full rounded flex items-center pl-2 text-[10px] font-bold text-white transition-all duration-700"
:style="{ width: bar.widthPct + '%', background: bar.color }"
>
{{ bar.value }}M
</div>
</div>
<div
class="text-[11px] font-bold text-[var(--ins-text-tertiary)] font-data min-w-[44px]"
>
{{ bar.pct }}%
</div>
</div>
</div>
</template>
<script setup>
import { computed } from "vue"
const props = defineProps({
bars: {
type: Array,
default: () => [],
// [{ name, value, pct, color }]
},
})
</script>

View File

@ -0,0 +1,162 @@
<template>
<div v-if="visible" class="mt-2" :class="{ 'detail-leaving': isLeaving }">
<!-- Back bar -->
<div class="detail-stage" style="animation-delay: 0s">
<div class="flex items-center gap-3 mb-2">
<button
class="flex items-center gap-1.5 px-4 py-2 rounded-lg border border-[var(--border)] bg-white cursor-pointer font-manrope text-[13px] font-bold text-[var(--ins-indigo)] transition-all duration-150 hover:bg-[var(--ins-indigo-dim)] hover:border-[var(--ins-indigo-border)] active:scale-[0.97]"
style="box-shadow: var(--shadow-sm)"
@click="handleClose"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
Назад
</button>
<span class="text-sm font-extrabold text-[var(--ins-text-primary)]">{{ title }}</span>
<span
v-if="badge"
class="text-[11px] font-bold px-2.5 py-[3px] rounded-full bg-[var(--ins-indigo-light)] text-[var(--ins-indigo)]"
>{{ badge }}</span>
</div>
</div>
<!-- Detail table -->
<div
class="bg-white border border-[var(--border)] rounded-xl overflow-hidden detail-stage"
style="box-shadow: var(--shadow-lg); animation-delay: 0.1s"
>
<div
class="flex items-center justify-between px-5 py-4 border-b border-[var(--border)] detail-stage"
style="animation-delay: 0.25s"
>
<div class="text-[15px] font-extrabold text-[var(--ins-text-primary)]">{{ title }}</div>
<button
class="w-8 h-8 rounded-md border border-[var(--border)] bg-white cursor-pointer flex items-center justify-center text-base text-[var(--ins-text-tertiary)] transition-all duration-150 hover:bg-[var(--bg-base)] hover:text-[var(--ins-text-primary)]"
@click="handleClose"
></button>
</div>
<div class="px-5 py-4">
<table class="w-full border-collapse">
<thead>
<tr>
<th
v-for="(col, i) in columns" :key="i"
class="text-left text-[11px] font-bold uppercase tracking-wider text-[var(--ins-text-tertiary)] px-3.5 py-3 bg-[var(--bg-page)] border-b-2 border-[var(--border)]"
>{{ col }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, ri) in rows" :key="ri"
class="hover:bg-[rgba(79,70,229,0.03)] row-stagger"
:style="{ animationDelay: `${0.35 + Math.floor(ri / 2) * 0.08}s` }"
>
<td
v-for="(cell, ci) in row" :key="ci"
class="text-[13px] font-medium text-[var(--ins-text-secondary)] px-3.5 py-[11px] border-b border-[var(--border-light)]"
v-html="formatCell(cell)"
/>
</tr>
</tbody>
</table>
</div>
<div
v-if="summary"
class="px-5 py-3.5 bg-[var(--bg-base)] border-t border-[var(--border)] text-xs font-semibold text-[var(--ins-text-secondary)] detail-stage"
style="animation-delay: 0.7s"
v-html="summary"
/>
</div>
</div>
</template>
<script setup>
import { ref, watch } from "vue"
const props = defineProps({
open: { type: Boolean, default: false },
title: { type: String, default: "" },
badge: { type: String, default: "" },
columns: { type: Array, default: () => [] },
rows: { type: Array, default: () => [] },
summary: { type: String, default: "" },
})
const emit = defineEmits(["close"])
const visible = ref(false)
const isLeaving = ref(false)
// Sync with open prop
watch(() => props.open, (val) => {
if (val) {
visible.value = true
isLeaving.value = false
}
// Close is handled by handleClose, not by prop change
})
function handleClose() {
// Start leave animation
isLeaving.value = true
// After animation completes, hide and emit
setTimeout(() => {
visible.value = false
isLeaving.value = false
emit("close")
}, 450)
}
function formatCell(val) {
const s = String(val)
if (/^\d{3}(\.\d+)?$/.test(s)) return `<span class="bg-[var(--bg-base)] px-2 py-[2px] rounded font-data text-[11px] font-semibold text-[var(--ins-indigo)] whitespace-nowrap">${s}</span>`
if (s === "✓") return `<span class="inline-flex items-center px-2.5 py-[3px] rounded-full text-[10px] font-bold bg-[var(--ins-positive-bg)] text-[var(--ins-positive)]">✓</span>`
if (s === "Открыта") return `<span class="inline-flex items-center px-2.5 py-[3px] rounded-full text-[10px] font-bold bg-[var(--ins-negative-bg)] text-[var(--ins-negative)]">${s}</span>`
if (s === "Закрыта") return `<span class="inline-flex items-center px-2.5 py-[3px] rounded-full text-[10px] font-bold bg-[var(--ins-positive-bg)] text-[var(--ins-positive)]">${s}</span>`
if (["В работе", "Ожидание", "Ожидает", "Ожид."].includes(s)) return `<span class="inline-flex items-center px-2.5 py-[3px] rounded-full text-[10px] font-bold bg-[var(--ins-warning-bg)] text-[var(--ins-warning)]">${s}</span>`
if (s === "СРОЧНО") return `<span class="inline-flex items-center px-2.5 py-[3px] rounded-full text-[10px] font-bold bg-[var(--ins-negative-bg)] text-[var(--ins-negative)]">${s}</span>`
if (s.startsWith("Риск ")) return `<span class="inline-flex items-center px-2.5 py-[3px] rounded-full text-[10px] font-bold bg-[var(--ins-negative-bg)] text-[var(--ins-negative)]">${s}</span>`
if (["Контроль", "Шкала", "Дедлайн"].includes(s)) return `<span class="inline-flex items-center px-2.5 py-[3px] rounded-full text-[10px] font-bold bg-[var(--ins-warning-bg)] text-[var(--ins-warning)]">${s}</span>`
if (["Критический", "Высокий"].includes(s)) return `<span class="inline-flex items-center px-2.5 py-[3px] rounded-full text-[10px] font-bold bg-[var(--ins-negative-bg)] text-[var(--ins-negative)]">${s}</span>`
if (s === "Средний") return `<span class="inline-flex items-center px-2.5 py-[3px] rounded-full text-[10px] font-bold bg-[var(--ins-warning-bg)] text-[var(--ins-warning)]">${s}</span>`
if (s === "Низкий") return `<span class="inline-flex items-center px-2.5 py-[3px] rounded-full text-[10px] font-bold bg-[var(--ins-positive-bg)] text-[var(--ins-positive)]">${s}</span>`
if (/^₼/.test(s)) return `<span class="font-data font-bold text-[13px] text-[var(--ins-text-primary)]">${s}</span>`
if (s === "▲") return `<span class="inline-flex items-center px-2.5 py-[3px] rounded-full text-[10px] font-bold bg-[var(--ins-warning-bg)] text-[var(--ins-warning)]">▲</span>`
if (s === "→") return `<span class="inline-flex items-center px-2.5 py-[3px] rounded-full text-[10px] font-bold bg-[var(--ins-positive-bg)] text-[var(--ins-positive)]">→</span>`
if (/^\d{1,3}$/.test(s) && Number(s) > 0) {
const n = Number(s)
if (n > 80) return `<span class="font-data font-bold text-[var(--ins-negative)]">${s}</span>`
if (n > 50) return `<span class="font-data font-bold text-[var(--ins-warning)]">${s}</span>`
return `<span class="font-data font-bold text-[var(--ins-positive)]">${s}</span>`
}
return s
}
</script>
<style scoped>
.detail-stage {
animation: detailEnter 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes detailEnter {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.detail-leaving {
animation: detailLeave 0.45s cubic-bezier(0.4, 0, 0.2, 1) both;
}
@keyframes detailLeave {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(14px) scale(0.98); }
}
.detail-leaving .detail-stage {
animation: none;
}
.detail-leaving .row-stagger {
animation: none;
opacity: 1;
transform: none;
}
</style>

View File

@ -0,0 +1,65 @@
<template>
<div
class="flex items-center gap-2 flex-wrap mb-5 px-4 py-2.5 bg-white border border-[var(--border)] rounded-lg"
style="box-shadow: var(--shadow-sm)"
>
<span
class="text-[10px] font-bold uppercase tracking-widest text-[var(--ins-text-tertiary)] mr-1"
>
{{ title }}
</span>
<template v-for="(group, gi) in groups" :key="gi">
<div
v-if="gi > 0 && group.separator"
class="w-px h-[22px] bg-[var(--border)] mx-1.5"
/>
<span
v-if="group.label"
class="text-[10px] font-bold uppercase tracking-widest text-[var(--ins-text-tertiary)] mr-1"
>
{{ group.label }}
</span>
<button
v-for="chip in group.chips"
:key="chip.key"
class="px-3 py-[5px] rounded-full cursor-pointer text-xs font-semibold border transition-all duration-150 select-none"
:class="
isActive(chip.key)
? 'bg-[var(--ins-indigo-light)] border-[var(--ins-indigo-border)] text-[var(--ins-indigo)]'
: 'bg-white border-[var(--border)] text-[var(--ins-text-tertiary)] hover:border-[var(--ins-indigo-border)] hover:text-[var(--ins-text-secondary)]'
"
@click="toggle(chip.key)"
>
{{ chip.label }}
</button>
</template>
</div>
</template>
<script setup>
const props = defineProps({
title: { type: String, default: "" },
groups: {
type: Array,
default: () => [],
// [{ label?, separator?, chips: [{ key, label }] }]
},
modelValue: {
type: Object,
default: () => ({}),
// { chipKey: true/false }
},
})
const emit = defineEmits(["update:modelValue"])
function isActive(key) {
return props.modelValue[key] !== false
}
function toggle(key) {
const newVal = { ...props.modelValue }
newVal[key] = !isActive(key)
emit("update:modelValue", newVal)
}
</script>

View File

@ -0,0 +1,76 @@
<template>
<div
class="relative bg-white border rounded-xl px-4 py-[18px] cursor-pointer select-none overflow-hidden transition-all duration-300"
:class="[
status === 'critical'
? 'border-red-500/30 animate-pulse-danger'
: status === 'warning'
? 'border-amber-500/30 animate-pulse-warning'
: 'border-[var(--border)]',
'hover:translate-y-[-3px] hover:scale-[1.008] hover:shadow-lg',
'active:translate-y-[1px] active:scale-[0.97] active:shadow-[var(--shadow-press)] active:duration-[60ms]',
]"
style="box-shadow: var(--shadow-sm)"
@click="$emit('click')"
>
<!-- Top accent line -->
<div
class="absolute top-0 left-0 right-0 h-[3px] rounded-t-xl transition-opacity duration-200"
:class="[
status === 'critical'
? 'opacity-100 bg-gradient-to-r from-red-600 to-red-400'
: status === 'warning'
? 'opacity-100 bg-gradient-to-r from-amber-600 to-amber-400'
: 'opacity-0 group-hover:opacity-100 bg-gradient-to-r from-[var(--ins-indigo)] to-purple-600',
]"
/>
<div class="flex items-center justify-between mb-2">
<span
class="text-[11px] font-bold text-[var(--ins-text-tertiary)] tracking-wider uppercase"
>
{{ label }}
</span>
<span
v-if="change"
class="text-[11px] font-bold px-2 py-[3px] rounded-full font-data"
:class="
changeUp
? 'bg-[var(--ins-positive-bg)] text-[var(--ins-positive)]'
: 'bg-[var(--ins-negative-bg)] text-[var(--ins-negative)]'
"
>
{{ change }}
</span>
</div>
<div
class="text-[26px] font-extrabold tracking-tight leading-none"
:class="status === 'critical' ? 'text-[var(--ins-negative)]' : 'text-[var(--ins-text-primary)]'"
:style="smallValue ? 'font-size: 18px' : ''"
>
{{ value }}
</div>
<div
v-if="subtitle"
class="text-xs text-[var(--ins-text-tertiary)] mt-1.5 font-medium"
>
{{ subtitle }}
</div>
</div>
</template>
<script setup>
defineProps({
label: String,
value: [String, Number],
subtitle: String,
change: String,
changeUp: { type: Boolean, default: true },
status: { type: String, default: "normal" },
smallValue: { type: Boolean, default: false },
})
defineEmits(["click"])
</script>

View File

@ -0,0 +1,466 @@
export const D = {
pulse: {
revenue: {
title: "Выручка — помесячно",
head: ["Месяц", "Выручка", "Расходы", "Прибыль", "Налоги"],
rows: [
["Январь", "₼320K", "₼245K", "₼75K", "₼48K"],
["Февраль", "₼355K", "₼260K", "₼95K", "₼52K"],
["Март", "₼410K", "₼280K", "₼130K", "₼61K"],
["Апрель", "₼380K", "₼270K", "₼110K", "₼55K"],
["Май", "₼365K", "₼255K", "₼110K", "₼54K"],
["Июнь", "₼340K", "₼248K", "₼92K", "₼49K"],
],
sum: "Итого за H1: <b>₼2.17M</b> · Средн.: <b>₼362K/мес.</b>",
},
taxload: {
title: "Налоговая нагрузка по видам",
head: ["Налог", "Сумма", "% от выручки", "Тренд"],
rows: [
["ƏDV", "₼285K", "6.8%", "▲"],
["Gəlir", "₼171K", "4.1%", "→"],
["DSMF+İTS", "₼162K", "3.9%", "→"],
["Mənfəət", "₼84K", "2.0%", "▲"],
["Прочие", "₼71K", "1.7%", "→"],
],
sum: "Общая нагрузка: <b>18.4%</b> от выручки",
},
employees: {
title: "Список сотрудников",
head: ["ФИО", "Должность", "Оклад", "DSMF", "Gəlir"],
rows: [
["Мамедов Р.А.", "Директор", "₼12,000", "₼3,000", "₼560"],
["Гулиев Э.Ф.", "Гл.бухгалтер", "₼26,000", "₼6,500", "₼2,770"],
["Сулейманов Н.И.", "Разработчик", "₼28,500", "₼7,125", "₼3,395"],
["Алиева С.А.", "HR менеджер", "₼9,500", "₼2,375", "₼210"],
["Гасанов Т.К.", "Менеджер", "₼8,200", "₼2,050", "₼28"],
["Исмаилова Л.Р.", "Ассистент", "₼7,800", "₼1,950", "₼0"],
["Прочие (18 чел.)", "—", "₼94,400", "₼23,600", "₼7,317"],
],
sum: "Всего: <b>24 чел.</b> · ФОТ: <b>₼186,400</b> · DSMF: <b>₼46,600</b>",
},
declarations: {
title: "Статус всех деклараций 2025",
head: ["Налог", "Периодичность", "Сдано", "Ожидает", "Просрочено"],
rows: [
["ƏDV", "Ежемесячно", "12", "0", "0"],
["Gəlir vergisi", "Ежемесячно", "12", "0", "0"],
["DSMF / İTS", "Ежемесячно", "11", "1", "0"],
["Mənfəət", "Ежеквартально", "3", "1", "0"],
["Mənbədə tutulan", "Ежемесячно", "10", "0", "2"],
["Əmlak", "Ежеквартально", "4", "0", "0"],
["Sadələşdirilmiş", "Ежеквартально", "4", "0", "0"],
],
sum: "Сдано: <b>56</b> · Ожидает: <b>2</b> · Просрочено: <b>2</b>",
},
inspections: {
title: "Открытые проверки",
head: ["VÖEN", "Налог", "Открыта", "Риск", "Статус"],
rows: [
["1234567891", "ƏDV", "12.02.2026", "85", "Открыта"],
["7778889990", "Mənbədə", "12.03.2026", "91", "Открыта"],
["1112223334", "Gəlir", "05.03.2026", "45", "В работе"],
],
sum: "Открытых: <b>3</b> · Макс. риск: <b>91</b>",
},
attention: {
title: "⚠ Требует внимания — сводка проблем",
head: ["Приоритет", "Проблема", "Налог / Область", "Детали", "Срок"],
rows: [
["СРОЧНО", "Дедлайн завтра!", "Mənfəət vergisi", "Годовая декларация не сдана", "31.03.2026"],
["СРОЧНО", "Просрочена декларация", "Mənbədə tutulan", "2 месячных декларации не сданы", "Просрочено!"],
["Риск 91", "Открытая проверка", "Mənbədə tutulan", "5 выплат нерезидентам без сертификата резидентства", "VÖEN 7778889990"],
["Риск 85", "Открытая проверка", "ƏDV", "3 e-Qaimə без подтверждения от контрагентов", "VÖEN 1234567891"],
["Контроль", "e-Qaimə расхождения", "ƏDV", "3 счёт-фактуры не подтверждены контрагентами", "Проверить до 20.04"],
["Шкала", "Превышение порога", "Gəlir vergisi", "2 сотрудника выше ₼25K — пересчитать прогрессивную шкалу", "Гулиев, Сулейманов"],
["Дедлайн", "Приближается", "DSMF + İTS + İşsizlik", "Ежемесячная уплата — через 16 дней", "15.04.2026"],
["Дедлайн", "Приближается", "ƏDV bəyannaməsi", "Ежемесячная декларация — через 21 день", "20.04.2026"],
],
sum: 'Критических: <b style="color:var(--ins-negative)">4</b> · Предупреждений: <b style="color:var(--ins-warning)">4</b>',
},
},
overview: {
total: {
title: "Все проверки за период",
head: ["VÖEN", "Компания", "Налог", "Дата", "Риск", "Статус"],
rows: [
["1234567891", "AzBuild MMC", "ƏDV", "12.02.2026", "85", "Открыта"],
["9876543210", "TradeMax LLC", "Mənfəət", "18.01.2026", "62", "В работе"],
["5555666677", "InfoTech MMC", "DSMF", "10.03.2026", "28", "Закрыта"],
["1112223334", "FinServ LLC", "Gəlir", "05.03.2026", "45", "Ожидание"],
["7778889990", "Import Group", "Mənbədə", "12.03.2026", "91", "Открыта"],
["3334445556", "GreenBuild", "Əmlak", "22.02.2026", "22", "Закрыта"],
],
sum: "Всего: <b>247</b> · Открытых: <b>79</b> · Закрытых: <b>168</b>",
},
taxsum: {
title: "Сумма налогов по видам",
head: ["Налог", "Начислено", "Уплачено", "Сальдо", "% от общего"],
rows: [
["ƏDV", "₼5.8M", "₼5.6M", "₼200K", "31.5%"],
["Mənfəət", "₼4.2M", "₼4.2M", "₼0", "22.8%"],
["Gəlir", "₼3.1M", "₼3.1M", "₼0", "16.8%"],
["DSMF", "₼1.5M", "₼1.5M", "₼0", "8.2%"],
["Mənbədə", "₼1.9M", "₼1.7M", "₼200K", "10.3%"],
["Прочие", "₼1.9M", "₼1.9M", "₼0", "10.3%"],
],
sum: "Итого: <b>₼18.4M</b> · Сальдо: <b>₼400K</b>",
},
saved: {
title: "Штрафы предотвращены — детали",
head: ["Компания", "Налог", "Потенциальный штраф", "Причина", "Действие"],
rows: [
["AzBuild MMC", "ƏDV", "₼580K", "Расхождение e-Qaimə", "Сверка проведена"],
["TradeMax LLC", "Mənfəət", "₼340K", "Ошибка в декларации", "Корректировка"],
["InfoTech MMC", "DSMF", "₼120K", "Неверная база", "Перерасчёт"],
["Import Group", "Mənbədə", "₼480K", "Нерезиденты без серт.", "Запрос сертификатов"],
["GreenBuild", "Əmlak", "₼90K", "Переоценка ОС", "Актуализация"],
],
sum: "Предотвращено: <b>₼2.1M</b> штрафов",
},
inprogress: {
title: "Незакрытые проверки",
head: ["VÖEN", "Компания", "Налог", "Дней открыта", "Риск"],
rows: [
["1234567891", "AzBuild MMC", "ƏDV", "47", "85"],
["7778889990", "Import Group", "Mənbədə", "18", "91"],
["1112223334", "FinServ LLC", "Gəlir", "25", "45"],
["2223334445", "LogiTrans", "Aksiz", "62", "38"],
["4445556667", "PharmaPlus", "İTS", "34", "32"],
],
sum: "В процессе: <b>79</b> · Средн. длительность: <b>58 дней</b>",
},
risk: {
title: "Риск-скор — топ компании",
head: ["Компания", "VÖEN", "Скор", "Осн. риск", "Уровень"],
rows: [
["Import Group", "7778889990", "91", "Mənbədə нерезиденты", "Критический"],
["AzBuild MMC", "1234567891", "85", "ƏDV e-Qaimə", "Высокий"],
["TradeMax LLC", "9876543210", "62", "Mənfəət декларация", "Средний"],
["FinServ LLC", "1112223334", "45", "Gəlir шкала", "Средний"],
["InfoTech MMC", "5555666677", "28", "DSMF база", "Низкий"],
],
sum: "Средний скор: <b>78</b> · Критических: <b>2</b>",
},
},
tax: {
edv: {
title: "ƏDV (НДС) — 18%",
badge: "18%",
head: ["Дата", "Счёт Дт", "Счёт Кт", "Сумма", "Описание"],
rows: [
["28.02", "211", "521.1", "₼48,200", "ƏDV с продаж"],
["28.02", "521.1", "241", "₼31,400", "Зачёт входящего"],
["20.03", "521.1", "223", "₼16,800", "Уплата в бюджет"],
["31.01", "211", "521.1", "₼52,100", "ƏDV с продаж янв."],
["20.02", "521.1", "223", "₼18,300", "Уплата янв."],
],
sum: "Начислено: <b>₼100,300</b> · Уплачено: <b>₼35,100</b>",
},
menfeet: {
title: "Mənfəət vergisi — 20%",
badge: "20%",
head: ["Дата", "Счёт Дт", "Счёт Кт", "Сумма", "Описание"],
rows: [
["31.03", "801", "521.2", "₼84,000", "Начисление за 2025"],
["31.03", "521.2", "223", "₼63,000", "Уплата с учётом авансов"],
["15.01", "521.2", "223", "₼21,000", "Аванс Q4"],
],
sum: "Начислено: <b>₼84,000</b> · Авансы: <b>₼63,000</b>",
},
gelir: {
title: "Gəlir vergisi — 014%",
badge: "014%",
head: ["ФИО", "Оклад", "Ставка", "Сумма НДФЛ", "Удержано"],
rows: [
["Мамедов Р.А.", "₼12,000", "14%", "₼560", "✓"],
["Гулиев Э.Ф.", "₼26,000", "14%+25%", "₼2,770", "✓"],
["Сулейманов Н.И.", "₼28,500", "14%+25%", "₼3,395", "✓"],
["Алиева С.А.", "₼9,500", "14%", "₼210", "✓"],
["Прочие (20 чел.)", "₼110,400", "014%", "₼7,345", "✓"],
],
sum: 'НДФЛ итого: <b>₼14,280/мес.</b> · <span style="color:var(--ins-negative)">2 чел. выше ₼25K</span>',
},
dsmf: {
title: "DSMF — 22%+3%",
badge: "25%",
head: ["Дата", "Счёт Дт", "Счёт Кт", "Сумма", "Описание"],
rows: [
["28.02", "731", "522", "₼41,008", "DSMF раб-ль 22%"],
["28.02", "533", "522", "₼5,592", "DSMF раб-к 3%"],
["15.03", "522", "223", "₼46,600", "Уплата DSMF"],
],
sum: "DSMF: <b>₼46,600/мес.</b> · 24 чел.",
},
its: {
title: "İTS — 2%+2%",
badge: "4%",
head: ["Дата", "Дт", "Кт", "Сумма", "Описание"],
rows: [
["28.02", "731", "522.1", "₼3,728", "İTS раб-ль 2%"],
["28.02", "533", "522.1", "₼3,728", "İTS раб-к 2%"],
["15.03", "522.1", "223", "₼7,456", "Уплата İTS"],
],
sum: "İTS: <b>₼7,456/мес.</b>",
},
issizlik: {
title: "İşsizlik — 0.5%+0.5%",
badge: "1%",
head: ["Дата", "Дт", "Кт", "Сумма", "Описание"],
rows: [
["28.02", "731", "522.2", "₼932", "İşsizlik раб-ль"],
["28.02", "533", "522.2", "₼932", "İşsizlik раб-к"],
["15.03", "522.2", "223", "₼1,864", "Уплата"],
],
sum: "İşsizlik: <b>₼1,864/мес.</b>",
},
menbede: {
title: "Mənbədə tutulan — 1015%",
badge: "1015%",
head: ["Дата", "Дт", "Кт", "Сумма", "Описание"],
rows: [
["15.03", "534", "521.5", "₼25,000", "10% дивиденды"],
["15.03", "537", "521.5", "₼3,200", "10% проценты"],
["10.03", "538", "521.5", "₼7,000", "14% роялти нерезид."],
["20.03", "521.5", "223", "₼35,200", "Уплата в бюджет"],
],
sum: 'Удержано: <b>₼39,700</b> · <span style="color:var(--ins-negative)">5 нерезидентов без серт.!</span>',
},
sadelesdirilmis: {
title: "Sadələşdirilmiş — 2%",
badge: "2%",
head: ["Квартал", "Оборот", "Налог 2%", "Уплачен", "Статус"],
rows: [
["Q1", "₼62K", "₼1,240", "₼1,240", "✓"],
["Q2", "₼58K", "₼1,160", "₼1,160", "✓"],
["Q3", "₼65K", "₼1,300", "₼1,300", "✓"],
["Q4", "₼71K", "₼1,420", "—", "Ожидает"],
],
sum: 'Оборот YTD: <b>₼256K</b> · <span style="color:var(--ins-negative)">Порог ₼200K — ƏDV регистрация!</span>',
},
aksiz: {
title: "Aksiz — ставки",
badge: "Фикс.",
head: ["Период", "Сумма", "Статус"],
rows: [
["Q1", "₼150K", "✓"],
["Q2", "₼148K", "✓"],
["Q3", "₼155K", "✓"],
["Q4", "₼147K", "Ожид."],
],
sum: "Итого: <b>₼600K</b>",
},
yol: {
title: "Yol vergisi",
badge: "Фикс.",
head: ["Авто", "Объём", "Ставка", "Сумма"],
rows: [
["Toyota Camry", "2.5L", "₼200", "₼200"],
["Mercedes Sprinter", "2.2L", "₼180", "₼180"],
],
sum: "Итого: <b>₼380</b>/год",
},
meden: {
title: "Mədən vergisi",
badge: "%",
head: ["Период", "Сумма", "Статус"],
rows: [["2025", "₼100K", "✓"]],
sum: "Итого: <b>₼100K</b>",
},
emlak: {
title: "Əmlak vergisi — 1%",
badge: "1%",
head: ["Квартал", "База ОС", "Налог 1%", "Статус"],
rows: [
["Q1", "₼10M", "₼25K", "✓"],
["Q2", "₼9.8M", "₼24.5K", "✓"],
["Q3", "₼9.6M", "₼24K", "✓"],
["Q4", "₼9.4M", "₼23.5K", "✓"],
],
sum: "Итого: <b>₼97K</b> · Совет: переоценка ОС",
},
torpaq: {
title: "Torpaq vergisi",
badge: "Фикс.",
head: ["Участок", "Площадь", "Зона", "Налог"],
rows: [
["Офис Баку", "450 м²", "Центр", "₼180K"],
["Склад Сумг.", "1200 м²", "Промзона", "₼120K"],
],
sum: "Итого: <b>₼300K</b>/год",
},
},
industry: {
assets: {
title: "Активы — группы счетов (0129)",
head: ["Счёт", "Название", "Оборот Дт", "Оборот Кт", "Сальдо"],
rows: [
["011", "Torpaq, tikili və avadanlıqlar", "₼1,240K", "₼180K", "₼9,860K"],
["021", "Qeyri-maddi aktivlər", "₼85K", "₼12K", "₼320K"],
["101", "İstehsalat ehtiyatları", "₼480K", "₼420K", "₼185K"],
["211", "Alıcılar və sifarişçilər", "₼3,200K", "₼2,860K", "₼340K"],
["221", "Qısamüddətli debitor borclar", "₼560K", "₼480K", "₼120K"],
["223", "Bank hesabları", "₼8,400K", "₼7,250K", "₼1,580K"],
["226", "ƏDV depozit hesabı", "₼45K", "₼30K", "₼15K"],
["241", "ƏDV əvəzləşdirilməsi", "₼680K", "₼680K", "₼0"],
],
sum: "Итого активы: <b>₼12.4M</b> · 8 групп · 142 проводки",
},
liabilities: {
title: "Обязательства — группы счетов (4055)",
head: ["Счёт", "Название", "Оборот Дт", "Оборот Кт", "Сальдо"],
rows: [
["431", "Uzunmüddətli bank kreditləri", "₼120K", "₼0", "₼2,400K"],
["501", "Qısamüddətli bank kreditləri", "₼350K", "₼500K", "₼850K"],
["521", "Vergi öhdəlikləri", "₼1,840K", "₼1,920K", "₼1,420K"],
["522", "DSMF / İTS öhdəlikləri", "₼560K", "₼560K", "₼46.6K"],
["531", "Malsatan və podratçılar", "₼2,100K", "₼2,350K", "₼1,680K"],
["533", "Əmək haqqı üzrə öhdəliklər", "₼2,232K", "₼2,232K", "₼186K"],
["545", "Digər qısamüddətli öhdəliklər", "₼180K", "₼240K", "₼620K"],
],
sum: "Итого обязательства: <b>₼7.2M</b> · 7 групп · 98 проводок",
},
equity: {
title: "Капитал — группы счетов (3034)",
head: ["Счёт", "Название", "Оборот Дт", "Оборот Кт", "Сальдо"],
rows: [
["301", "Nizamnamə kapitalı", "₼0", "₼0", "₼3,000K"],
["331", "Hesabat dövrünün mənfəəti", "₼0", "₼840K", "₼840K"],
["332", "Keçmiş illərin mənfəəti", "₼250K", "₼0", "₼1,200K"],
["341", "Hesabat dövrü üzrə xərclər", "₼160K", "₼0", "₼160K"],
],
sum: "Итого капитал: <b>₼5.2M</b> · 4 группы · 18 проводок",
},
income: {
title: "Доходы — группы счетов (6069)",
head: ["Счёт", "Название", "Оборот Дт", "Оборот Кт", "Сальдо"],
rows: [
["601", "Satış gəlirləri", "₼0", "₼4,200K", "₼4,200K"],
["611", "Sair əməliyyat gəlirləri", "₼0", "₼380K", "₼380K"],
["621", "Maliyyə gəlirləri", "₼0", "₼185K", "₼185K"],
["631", "Fövqəladə gəlirlər", "₼0", "₼35K", "₼35K"],
],
sum: "Итого доходы: <b>₼4.8M</b> · 4 группы · 86 проводок",
},
expenses: {
title: "Расходы — группы счетов (7080)",
head: ["Счёт", "Название", "Оборот Дт", "Оборот Кт", "Сальдо"],
rows: [
["701", "Satışın maya dəyəri", "₼1,680K", "₼0", "₼1,680K"],
["711", "Kommersiya xərcləri", "₼420K", "₼0", "₼420K"],
["721", "İnzibati xərclər", "₼890K", "₼0", "₼890K"],
["731", "Əmək haqqı xərcləri", "₼560K", "₼0", "₼560K"],
["801", "Mənfəət vergisi xərcləri", "₼84K", "₼0", "₼84K"],
["741", "Sair əməliyyat xərcləri", "₼266K", "₼0", "₼266K"],
],
sum: "Итого расходы: <b>₼3.9M</b> · 6 групп · 124 проводки",
},
},
region: {
rates: {
title: "Налоговые ставки — ВКРП 2026",
head: ["Налог", "Статья НК", "Ставка", "Примечание", "Действует с"],
rows: [
["ƏDV (НДС)", "Ст. 175", "18%", "Стандартная ставка", "01.01.2026"],
["Mənfəət vergisi", "Ст. 105.1", "20%", "Юр. лица", "01.01.2026"],
["Gəlir (до ₼8K)", "Ст. 101.1", "0%", "Необлагаемый минимум", "01.01.2026"],
["Gəlir (₼8K₼30K)", "Ст. 101.1", "14%", "Основная ставка", "01.01.2026"],
["Gəlir (свыше ₼30K)", "Ст. 101.1", "25%", "Повышенная", "01.01.2026"],
["Sadələşdirilmiş", "Ст. 218", "2%", "Упрощённый режим", "01.01.2026"],
["Əmlak vergisi (юр.)", "Ст. 199", "1%", "От остаточной стоимости", "01.01.2026"],
["Torpaq vergisi", "Ст. 206", "₼0.06/м²", "Зависит от зоны", "01.01.2026"],
["Mənbədə tutulan", "Ст. 150", "10%", "Дивиденды, проценты", "01.01.2026"],
],
sum: "Источник: <b>Налоговый кодекс АР</b> · Редакция 2026",
},
social: {
title: "Социальные взносы — 2026",
head: ["Взнос", "Работодатель", "Работник", "Итого", "База"],
rows: [
["DSMF (пенсия)", "22%", "3%", "25%", "Начисленная ЗП"],
["İTS (страхование)", "2%", "2%", "4%", "Начисленная ЗП"],
["İşsizlik (безработица)", "0.5%", "0.5%", "1%", "Начисленная ЗП"],
["<b>ИТОГО</b>", "<b>24.5%</b>", "<b>5.5%</b>", "<b>30%</b>", "—"],
],
sum: "Закон: <b>О социальном страховании</b>",
},
limits: {
title: "Пороги и лимиты — 2026",
head: ["Параметр", "Значение", "Основание", "Действует с"],
rows: [
["Минимальная зарплата (МЗП)", "₼400", "Указ Президента", "01.01.2026"],
["Порог ƏDV регистрации", "₼200,000", "Ст. 155 НК", "01.01.2026"],
["Необлагаемый Gəlir (год)", "₼8,000", "Ст. 102.1 НК", "01.01.2026"],
["Лимит Sadələşdirilmiş", "₼200,000", "Ст. 218 НК", "01.01.2026"],
["Лимит наличных расчётов", "₼30,000", "Ст. 16.1 НК", "01.01.2026"],
["Амортизация — здания", "7%", "Ст. 114 НК", "01.01.2026"],
["Амортизация — оборудование", "20%", "Ст. 114 НК", "01.01.2026"],
["Амортизация — транспорт", "25%", "Ст. 114 НК", "01.01.2026"],
],
sum: "Клик на значение для редактирования",
},
inspection: {
title: "Параметры инспекции",
head: ["Параметр", "Значение", "Описание"],
rows: [
["Риск-скор: высокий", ">80", "Красная зона — срочная проверка"],
["Риск-скор: средний", "5080", "Жёлтая зона — наблюдение"],
["Риск-скор: низкий", "<50", "Зелёная зона — норма"],
["Дедлайн ƏDV", "20 числа сл. мес.", "Ежемесячная подача"],
["Дедлайн Mənfəət", "31 марта", "Годовая декларация"],
["Дедлайн DSMF", "15 числа сл. мес.", "Ежемесячный платёж"],
["Частота проверок ƏDV", "Ежемесячно", "Автоматическая сверка"],
["Частота проверок Mənfəət", "Ежеквартально", "По закрытии периода"],
["Уведомление за дней", "5", "До дедлайна"],
],
sum: "Клик на значение для редактирования",
},
},
// Pulse page chart data
charts: {
months: ["Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"],
revenue: [320, 355, 410, 380, 365, 340, 360, 350, 390, 370, 345, 315],
taxes: [48, 52, 61, 55, 54, 49, 53, 51, 58, 54, 50, 45],
taxesPaid: [40, 45, 55, 50, 48, 44, 48, 46, 52, 49, 45, 40],
},
// Pulse KPI values
kpi: {
revenue: { value: "₼4.2M", change: "+12%", up: true },
taxload: { value: "18.4%", change: "+1.2%", up: false },
employees: { value: "24", change: "+3", up: true },
declarations: { value: "8/13", change: "5 ожид.", up: false },
inspections: { value: "3", change: "Риск 91", up: false },
},
// Deadlines for Pulse page
deadlines: [
{ icon: "📋", title: "Mənfəət vergisi — годовая", date: "31.03.2026", days: 1, urgency: "urgent" },
{ icon: "💰", title: "DSMF + İTS + İşsizlik", date: "15.04.2026", days: 16, urgency: "urgent" },
{ icon: "📋", title: "ƏDV bəyannaməsi", date: "20.04.2026", days: 21, urgency: "soon" },
{ icon: "💰", title: "Əmlak — аванс Q2", date: "15.05.2026", days: 46, urgency: "ok" },
],
// Declaration status for segmented bars
declarationStatus: [
{ name: "ƏDV", filed: 12, total: 12, late: 0 },
{ name: "Gəlir vergisi", filed: 12, total: 12, late: 0 },
{ name: "DSMF / İTS", filed: 11, total: 12, late: 0 },
{ name: "Mənfəət", filed: 3, total: 4, late: 0 },
{ name: "Mənbədə", filed: 10, total: 12, late: 2 },
],
// Tax structure for horizontal bars
taxStructure: [
{ name: "ƏDV", value: 5.8, pct: 31.5, color: "#4f46e5" },
{ name: "Mənfəət", value: 4.2, pct: 22.8, color: "#7c3aed" },
{ name: "Gəlir", value: 3.1, pct: 16.8, color: "#2563eb" },
{ name: "Mənbədə", value: 1.9, pct: 10.3, color: "#dc2626" },
{ name: "DSMF", value: 1.5, pct: 8.2, color: "#059669" },
{ name: "Прочие", value: 1.9, pct: 10.3, color: "#94a3b8" },
],
// Health score
healthScore: 75,
}

View File

@ -0,0 +1,42 @@
import { reactive, computed } from "vue"
import { D } from "./mockData"
const state = reactive({
data: D,
activeFilters: {},
period: {
year: "2025",
quarters: ["Q4"],
months: [],
},
loading: false,
})
export function useStore() {
const getData = (ctx, key) => {
return state.data[ctx]?.[key] || null
}
const getKpi = (key) => {
return state.data.kpi?.[key] || null
}
const setLoading = (val) => {
state.loading = val
}
const updateSetting = (key, value) => {
// For now, just local mutation
// Later: frappe.call to persist
console.log(`Setting ${key} = ${value}`)
}
return {
state,
getData,
getKpi,
setLoading,
updateSetting,
D: state.data,
}
}

187
frontend/src/main.css Normal file
View File

@ -0,0 +1,187 @@
@import "frappe-ui/src/style.css";
:root {
--bg-base: #f1f5f9;
--bg-page: #f8fafc;
--bg-card: #fff;
--border: #e2e8f0;
--border-light: #f1f5f9;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.08);
--shadow-press: inset 0 1px 3px rgba(0, 0, 0, 0.08);
--ins-indigo: #4f46e5;
--ins-indigo-light: #e0e7ff;
--ins-indigo-dim: rgba(79, 70, 229, 0.08);
--ins-indigo-border: rgba(79, 70, 229, 0.25);
--ins-positive: #059669;
--ins-positive-bg: #ecfdf5;
--ins-negative: #dc2626;
--ins-negative-bg: #fef2f2;
--ins-warning: #d97706;
--ins-warning-bg: #fffbeb;
--ins-info: #2563eb;
--ins-info-bg: #eff6ff;
--ins-text-primary: #0f172a;
--ins-text-secondary: #475569;
--ins-text-tertiary: #94a3b8;
--ins-radius: 12px;
--ins-radius-sm: 8px;
--ins-radius-xs: 6px;
--ins-header-h: 56px;
--ins-sidebar-w: 56px;
}
body {
font-family: "Manrope", -apple-system, sans-serif;
background: var(--bg-page);
color: var(--ins-text-primary);
-webkit-font-smoothing: antialiased;
}
/* Glassmorphism utility */
.glass {
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
background: rgba(255, 255, 255, 0.65);
border: 1px solid rgba(255, 255, 255, 0.5);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.04), inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
/* Animations */
@keyframes pulseDanger {
0%, 100% { box-shadow: var(--shadow-sm), 0 0 0 0 rgba(220, 38, 38, 0.15); }
50% { box-shadow: var(--shadow-sm), 0 0 0 6px rgba(220, 38, 38, 0.06); }
}
@keyframes pulseWarning {
0%, 100% { box-shadow: var(--shadow-sm), 0 0 0 0 rgba(217, 119, 6, 0.15); }
50% { box-shadow: var(--shadow-sm), 0 0 0 6px rgba(217, 119, 6, 0.06); }
}
@keyframes donutPulse {
0%, 100% { filter: drop-shadow(0 0 8px rgba(79, 70, 229, 0.15)); }
50% { filter: drop-shadow(0 0 20px rgba(79, 70, 229, 0.35)); }
}
@keyframes donutBreathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.02); }
}
@keyframes ringSpin {
from { transform: rotate(-90deg); }
to { transform: rotate(270deg); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes dotPulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.5); opacity: 0.5; }
}
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes timelinePulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.3); }
50% { box-shadow: 0 0 0 5px rgba(220, 38, 38, 0.08); }
}
.animate-pulse-danger {
animation: pulseDanger 3s ease-in-out infinite;
}
.animate-pulse-warning {
animation: pulseWarning 3.5s ease-in-out infinite;
}
.animate-shimmer {
background: linear-gradient(90deg, var(--bg-base) 25%, #e2e8f0 50%, var(--bg-base) 75%);
background-size: 200% 100%;
animation: shimmer 1.2s infinite;
}
.animate-fade-slide-in {
animation: fadeSlideIn 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}
/* Content swap states */
.content-swap {
opacity: 1;
transform: translateY(0);
}
.content-swap.fading-out {
opacity: 0;
transform: scale(0.97) translateY(-6px);
pointer-events: none;
transition: opacity 0.35s ease, transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
}
.content-swap.gone {
display: none;
}
.content-swap.fading-in {
opacity: 0;
transform: translateY(16px);
}
.content-swap.revealing {
opacity: 1;
transform: translateY(0);
transition: opacity 0.6s cubic-bezier(0.22, 1, 0.36, 1), transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
/* Staggered row reveal */
.row-stagger {
opacity: 0;
transform: translateY(10px);
animation: fadeSlideIn 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
/* Inspect button */
.inspect-btn {
width: 60px;
height: 60px;
border-radius: 50%;
border: none;
background: #059669;
color: #fff;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
flex-shrink: 0;
box-shadow: 0 4px 16px rgba(5, 150, 105, 0.3);
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.3s ease, background 0.2s ease;
}
.inspect-btn:hover {
transform: scale(1.08);
background: #047857;
box-shadow: 0 6px 24px rgba(5, 150, 105, 0.4);
}
.inspect-btn:active {
transform: scale(0.92);
transition: transform 60ms ease;
}
.inspect-btn.loading {
background: #047857;
}
.inspect-btn svg {
display: block;
flex-shrink: 0;
}
.inspect-btn span {
font-family: 'Manrope', sans-serif;
font-size: 8px;
font-weight: 800;
letter-spacing: 0.5px;
text-transform: uppercase;
line-height: 1;
color: #fff;
flex-shrink: 0;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
/* Mono text for data values */
.font-data {
font-family: "Source Code Pro", monospace;
}

33
frontend/src/main.js Normal file
View File

@ -0,0 +1,33 @@
import { createApp } from "vue"
import App from "./App.vue"
import router from "./router"
import {
Button,
setConfig,
frappeRequest,
resourcesPlugin,
} from "frappe-ui"
import "./main.css"
const app = createApp(App)
setConfig("resourceFetcher", frappeRequest)
app.use(resourcesPlugin)
app.component("Button", Button)
app.use(router)
router.isReady().then(async () => {
if (import.meta.env.DEV) {
await frappeRequest({
url: "/api/method/inspector.www.inspector.get_context_for_dev",
}).then((values) => {
if (!window.frappe) window.frappe = {}
window.frappe.boot = values
})
}
app.mount("#app")
})

View File

@ -0,0 +1,73 @@
<template>
<div class="animate-fade-slide-in">
<FilterBar
title="ПЛАН СЧЕТОВ"
:groups="filterGroups"
v-model="filters"
/>
<div class="content-swap mb-5" :class="cardsClass">
<div class="grid grid-cols-5 gap-3">
<KpiCard
v-for="card in visibleCards"
:key="card.key"
v-bind="card"
@click="onCardClick(card.key)"
/>
</div>
</div>
<DetailPanel
:open="detailOpen"
:title="detailData?.title || ''"
:columns="detailData?.head || []"
:rows="detailData?.rows || []"
:summary="detailData?.sum || ''"
@close="closeDetail"
/>
</div>
</template>
<script setup>
import { ref, computed } from "vue"
import FilterBar from "@/components/shared/FilterBar.vue"
import KpiCard from "@/components/shared/KpiCard.vue"
import DetailPanel from "@/components/shared/DetailPanel.vue"
import { useStore } from "@/data/store"
import { useContentSwap } from "@/utils/useContentSwap"
const { D } = useStore()
const { cardsClass, detailOpen, openDetail, closeDetail } = useContentSwap()
const filters = ref({})
const detailData = ref(null)
const filterGroups = [
{
chips: [
{ key: "assets", label: "Активы" },
{ key: "liabilities", label: "Обязательства" },
{ key: "equity", label: "Капитал" },
{ key: "income", label: "Доходы" },
{ key: "expenses", label: "Расходы" },
],
},
]
const allCards = [
{ key: "assets", label: "Активы", value: "₼12.4M", subtitle: "Счета 0129", change: "+8%", changeUp: true, status: "normal" },
{ key: "liabilities", label: "Обязательства", value: "₼7.2M", subtitle: "Счета 4055", change: "+3%", changeUp: false, status: "normal" },
{ key: "equity", label: "Капитал", value: "₼5.2M", subtitle: "Счета 3034", change: "+12%", changeUp: true, status: "normal" },
{ key: "income", label: "Доходы", value: "₼4.8M", subtitle: "Счета 6069", change: "+15%", changeUp: true, status: "normal" },
{ key: "expenses", label: "Расходы", value: "₼3.9M", subtitle: "Счета 7080", change: "+6%", changeUp: false, status: "normal" },
]
const visibleCards = computed(() =>
allCards.filter((c) => filters.value[c.key] !== false)
)
function onCardClick(key) {
detailData.value = D.industry[key] || null
if (detailData.value) openDetail()
}
</script>

View File

@ -0,0 +1,73 @@
<template>
<div class="animate-fade-slide-in">
<FilterBar
title="ПОКАЗАТЕЛИ"
:groups="filterGroups"
v-model="filters"
/>
<div class="content-swap mb-5" :class="cardsClass">
<div class="grid grid-cols-5 gap-3">
<KpiCard
v-for="card in visibleCards"
:key="card.key"
v-bind="card"
@click="onCardClick(card.key)"
/>
</div>
</div>
<DetailPanel
:open="detailOpen"
:title="detailData?.title || ''"
:columns="detailData?.head || []"
:rows="detailData?.rows || []"
:summary="detailData?.sum || ''"
@close="closeDetail"
/>
</div>
</template>
<script setup>
import { ref, computed } from "vue"
import FilterBar from "@/components/shared/FilterBar.vue"
import KpiCard from "@/components/shared/KpiCard.vue"
import DetailPanel from "@/components/shared/DetailPanel.vue"
import { useStore } from "@/data/store"
import { useContentSwap } from "@/utils/useContentSwap"
const { D } = useStore()
const { cardsClass, detailOpen, openDetail, closeDetail } = useContentSwap()
const filters = ref({})
const detailData = ref(null)
const filterGroups = [
{
chips: [
{ key: "total", label: "Проверки" },
{ key: "taxsum", label: "Налоги" },
{ key: "saved", label: "Штрафы" },
{ key: "inprogress", label: "В процессе" },
{ key: "risk", label: "Риск" },
],
},
]
const allCards = [
{ key: "total", label: "Всего проверок", value: "247", subtitle: "за период", change: "+12%", changeUp: true, status: "normal" },
{ key: "taxsum", label: "Сумма налогов", value: "₼18.4M", subtitle: "начислено", change: "+8.3%", changeUp: true, status: "normal" },
{ key: "saved", label: "Штрафы предотвр.", value: "₼2.1M", subtitle: "экономия", change: "+23%", changeUp: true, status: "normal" },
{ key: "inprogress", label: "В процессе", value: "79", subtitle: "незакрытых", change: "32.2%", changeUp: false, status: "warning" },
{ key: "risk", label: "Риск-скор", value: "78", subtitle: "внимание", change: "", changeUp: false, status: "critical" },
]
const visibleCards = computed(() =>
allCards.filter((c) => filters.value[c.key] !== false)
)
function onCardClick(key) {
detailData.value = D.overview[key] || null
if (detailData.value) openDetail()
}
</script>

View File

@ -0,0 +1,327 @@
<template>
<div class="animate-fade-slide-in relative">
<!-- Header row -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-xl font-extrabold text-[var(--ins-text-primary)]">
Пульс компании
</h1>
<p class="text-sm text-[var(--ins-text-tertiary)] font-medium mt-0.5">
Jey Soft MMC · Обзор налогового здоровья
</p>
</div>
<div class="flex items-center gap-3.5">
<!-- Date range (always shown after first inspect) -->
<span
v-if="inspected"
class="font-manrope text-sm font-bold text-[var(--ins-text-primary)] tracking-tight"
>
{{ dateRange }}
</span>
<!-- Period button (always available) -->
<div class="relative" ref="periodWrap">
<button
class="flex items-center gap-1.5 px-3.5 py-[7px] rounded-lg glass cursor-pointer font-manrope text-xs font-bold text-[var(--ins-indigo)] transition-all duration-150 hover:border-[var(--ins-indigo-border)] hover:shadow-md"
@click.stop="popoverOpen = !popoverOpen"
>
📅 Период
<svg width="10" height="6" viewBox="0 0 10 6" fill="none">
<path d="M1 1L5 5L9 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<!-- Popover -->
<div
v-if="popoverOpen"
class="absolute top-[calc(100%+8px)] right-0 w-[340px] bg-white/95 backdrop-blur-[20px] border border-[var(--border)] rounded-xl p-4 z-[999] animate-fade-slide-in"
style="box-shadow: 0 16px 48px rgba(0,0,0,.15), 0 4px 12px rgba(0,0,0,.08)"
@click.stop
>
<h4 class="text-[10px] font-bold text-[var(--ins-text-tertiary)] uppercase tracking-wider mb-2">Год</h4>
<div class="flex flex-wrap gap-[5px] mb-3.5">
<button
v-for="y in ['2024','2025','2026']" :key="y"
class="px-2.5 py-1 rounded-full text-[11px] font-semibold border transition-all duration-150"
:class="period.year === y ? 'bg-[var(--ins-indigo-light)] border-[var(--ins-indigo-border)] text-[var(--ins-indigo)]' : 'bg-white border-[var(--border)] text-[var(--ins-text-tertiary)]'"
@click="period.year = y"
>{{ y }}</button>
</div>
<h4 class="text-[10px] font-bold text-[var(--ins-text-tertiary)] uppercase tracking-wider mb-2">Квартал</h4>
<div class="flex flex-wrap gap-[5px] mb-3.5">
<button
v-for="q in ['Q1','Q2','Q3','Q4']" :key="q"
class="px-2.5 py-1 rounded-full text-[11px] font-semibold border transition-all duration-150"
:class="period.quarters.includes(q) ? 'bg-[var(--ins-indigo-light)] border-[var(--ins-indigo-border)] text-[var(--ins-indigo)]' : 'bg-white border-[var(--border)] text-[var(--ins-text-tertiary)]'"
@click="toggleQ(q)"
>{{ q }}</button>
</div>
<h4 class="text-[10px] font-bold text-[var(--ins-text-tertiary)] uppercase tracking-wider mb-2">Месяц</h4>
<div class="flex flex-wrap gap-[5px] mb-4">
<button
v-for="(m, mi) in monthLabels" :key="m"
class="px-2.5 py-1 rounded-full text-[11px] font-semibold border transition-all duration-150"
:class="isMonthActive(mi) ? 'bg-[var(--ins-indigo-light)] border-[var(--ins-indigo-border)] text-[var(--ins-indigo)]' : 'bg-white border-[var(--border)] text-[var(--ins-text-tertiary)]'"
@click="toggleMonth(mi)"
>{{ m }}</button>
</div>
<!-- OK button -->
<button
class="w-full py-2 rounded-lg text-xs font-bold text-white transition-all duration-150 hover:opacity-90 active:scale-[0.98]"
style="background: linear-gradient(135deg, var(--ins-indigo), #7c3aed)"
@click="popoverOpen = false"
>
OK
</button>
</div>
</div>
<!-- Inspect button -->
<button
class="inspect-btn"
:class="loading ? 'loading' : ''"
@click="doInspect"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M22 12h-4l-3 9L9 3l-3 9H2" stroke="#ffffff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>Inspect</span>
</button>
</div>
</div>
<!-- Content -->
<div
class="content-swap"
:class="cardsClass"
:style="{ opacity: loading ? 0.3 : undefined, transition: loading ? 'opacity 0.4s ease' : undefined }"
>
<!-- Row 1: KPI Circles -->
<div class="flex justify-between gap-3 mb-6">
<PulseKpiCircle
v-for="kpi in kpis" :key="kpi.key"
:label="kpi.label" :value="kpi.value" :change="kpi.change"
:up="kpi.up" :color="kpi.color" :skeleton="loading"
:value-style="kpi.valueStyle || ''"
@click="onOpenDetail('pulse', kpi.key)"
/>
</div>
<!-- Row 2: Trend chart + Health Score -->
<div class="grid grid-cols-[2fr_1fr] gap-3 mb-3.5">
<div class="glass rounded-xl p-5">
<div class="text-[13px] font-bold text-[var(--ins-text-primary)] mb-4 flex items-center justify-between">
Тренд: Выручка vs Налоги
<span class="text-[10px] font-semibold text-[var(--ins-text-tertiary)] px-2.5 py-[3px] bg-[var(--bg-base)] rounded-full">12 мес.</span>
</div>
<svg class="w-full overflow-visible" viewBox="0 0 500 160" preserveAspectRatio="none">
<defs>
<linearGradient id="revGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--ins-indigo)" stop-opacity="0.12"/>
<stop offset="100%" stop-color="var(--ins-indigo)" stop-opacity="0"/>
</linearGradient>
<linearGradient id="taxGrad2" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--ins-warning)" stop-opacity="0.10"/>
<stop offset="100%" stop-color="var(--ins-warning)" stop-opacity="0"/>
</linearGradient>
</defs>
<line v-for="y in [40,80,120]" :key="y" x1="0" :y1="y" x2="500" :y2="y" stroke="var(--border-light)" stroke-width="0.5"/>
<path class="opacity-[0.08]" d="M20,80 L60,72 L100,75 L140,60 L180,52 L220,48 L260,44 L300,40 L340,36 L380,30 L420,28 L460,22 L460,140 L20,140Z" fill="url(#revGrad)"/>
<path d="M20,80 L60,72 L100,75 L140,60 L180,52 L220,48 L260,44 L300,40 L340,36 L380,30 L420,28 L460,22" fill="none" stroke="var(--ins-indigo)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="460" cy="22" r="3" fill="var(--ins-indigo)" stroke="#fff" stroke-width="2"/>
<path class="opacity-[0.08]" d="M20,115 L60,112 L100,110 L140,108 L180,106 L220,102 L260,100 L300,96 L340,94 L380,92 L420,88 L460,84 L460,140 L20,140Z" fill="url(#taxGrad2)"/>
<path d="M20,115 L60,112 L100,110 L140,108 L180,106 L220,102 L260,100 L300,96 L340,94 L380,92 L420,88 L460,84" fill="none" stroke="var(--ins-warning)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="460" cy="84" r="3" fill="var(--ins-warning)" stroke="#fff" stroke-width="2"/>
<text v-for="(m, i) in chartMonths" :key="i" class="text-[9px] fill-[var(--ins-text-tertiary)] font-manrope font-semibold" :x="20 + i * 40" y="155">{{ m }}</text>
<circle cx="180" cy="10" r="4" fill="var(--ins-indigo)"/><text class="text-[9px] fill-[var(--ins-text-tertiary)] font-manrope font-semibold" x="188" y="13">Выручка</text>
<circle cx="250" cy="10" r="4" fill="var(--ins-warning)"/><text class="text-[9px] fill-[var(--ins-text-tertiary)] font-manrope font-semibold" x="258" y="13">Налоги</text>
</svg>
</div>
<div class="glass rounded-xl flex flex-col items-center justify-center">
<div class="text-[13px] font-bold text-[var(--ins-text-primary)] mb-4 w-full px-5 flex items-center justify-between">
Health Score
<span class="text-[10px] font-semibold text-[var(--ins-text-tertiary)] px-2.5 py-[3px] bg-[var(--bg-base)] rounded-full">2025</span>
</div>
<HealthDonut :score="D.healthScore" :spinning="loading" @attention="onOpenDetail('pulse', 'attention')" />
</div>
</div>
<!-- Row 3: Tax Structure + Deadlines -->
<div class="grid grid-cols-2 gap-3 mb-3.5">
<div class="glass rounded-xl p-5">
<div class="text-[13px] font-bold text-[var(--ins-text-primary)] mb-4 flex items-center justify-between">
Налоговая структура
<span class="text-[10px] font-semibold text-[var(--ins-text-tertiary)] px-2.5 py-[3px] bg-[var(--bg-base)] rounded-full">по видам</span>
</div>
<TaxStructureBars :bars="taxBars" />
</div>
<div class="glass rounded-xl p-5">
<div class="text-[13px] font-bold text-[var(--ins-text-primary)] mb-4 flex items-center justify-between">
Ближайшие дедлайны
<span class="text-[10px] font-semibold text-[var(--ins-text-tertiary)] px-2.5 py-[3px] bg-[var(--bg-base)] rounded-full">45 дней</span>
</div>
<DeadlineTimeline :items="D.deadlines" />
</div>
</div>
<!-- Row 4: Declarations + Monthly Chart -->
<div class="grid grid-cols-2 gap-3">
<div class="glass rounded-xl p-5">
<div class="text-[13px] font-bold text-[var(--ins-text-primary)] mb-4 flex items-center justify-between">
Статус деклараций
<span class="text-[10px] font-semibold text-[var(--ins-text-tertiary)] px-2.5 py-[3px] bg-[var(--bg-base)] rounded-full">2025</span>
</div>
<DeclarationBars :items="D.declarationStatus" />
</div>
<div class="glass rounded-xl p-5">
<div class="text-[13px] font-bold text-[var(--ins-text-primary)] mb-4 flex items-center justify-between">
Налоги по месяцам
<span class="text-[10px] font-semibold text-[var(--ins-text-tertiary)] px-2.5 py-[3px] bg-[var(--bg-base)] rounded-full"> тыс.</span>
</div>
<div class="flex items-end gap-1 h-[120px] pb-5 relative">
<div class="absolute bottom-5 left-0 right-0 h-px bg-[var(--border-light)]" />
<div v-for="(m, i) in chartMonths" :key="i" class="flex-1 flex flex-col items-center gap-[2px]">
<div class="w-full rounded-t-[3px] transition-all duration-300 min-h-[2px]" :style="{ height: barHeight(D.charts.taxes[i], 70) + '%', background: 'var(--ins-indigo)' }" />
<div class="w-full rounded-t-[3px] transition-all duration-300 min-h-[2px] opacity-50" :style="{ height: barHeight(D.charts.taxesPaid[i], 70) + '%', background: 'var(--ins-warning)' }" />
<div class="text-[8px] text-[var(--ins-text-tertiary)] font-semibold">{{ m }}</div>
</div>
</div>
<div class="flex gap-3 text-[10px] font-bold mt-1">
<span class="text-[var(--ins-indigo)]"> Начислено</span>
<span class="text-[var(--ins-warning)]"> Уплачено</span>
</div>
</div>
</div>
</div>
<!-- Detail panel -->
<DetailPanel
:open="detailOpen"
:title="detailData?.title || ''"
:badge="detailData?.badge || ''"
:columns="detailData?.head || []"
:rows="detailData?.rows || []"
:summary="detailData?.sum || ''"
@close="closeDetail"
/>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch, onMounted, onUnmounted } from "vue"
import PulseKpiCircle from "@/components/pulse/PulseKpiCircle.vue"
import HealthDonut from "@/components/pulse/HealthDonut.vue"
import TaxStructureBars from "@/components/pulse/TaxStructureBars.vue"
import DeadlineTimeline from "@/components/pulse/DeadlineTimeline.vue"
import DeclarationBars from "@/components/pulse/DeclarationBars.vue"
import DetailPanel from "@/components/shared/DetailPanel.vue"
import { useStore } from "@/data/store"
import { useContentSwap } from "@/utils/useContentSwap"
const { D } = useStore()
const { cardsClass, detailOpen, openDetail, closeDetail } = useContentSwap()
const loading = ref(false)
const inspected = ref(false)
const popoverOpen = ref(false)
const detailData = ref(null)
const monthLabels = ["Yan", "Fev", "Mar", "Apr", "May", "İyn", "İyl", "Avq", "Sen", "Okt", "Noy", "Dek"]
const chartMonths = ["Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"]
const period = reactive({
year: "2025",
quarters: ["Q4"],
months: [9, 10, 11], // 0-indexed month indices
})
// Quarter month mapping (0-indexed)
const qMonths = { Q1: [0,1,2], Q2: [3,4,5], Q3: [6,7,8], Q4: [9,10,11] }
function toggleQ(q) {
const idx = period.quarters.indexOf(q)
if (idx >= 0) {
period.quarters.splice(idx, 1)
} else {
period.quarters.push(q)
}
syncMonthsFromQuarters()
}
function syncMonthsFromQuarters() {
const active = new Set()
for (const q of period.quarters) {
for (const m of qMonths[q]) active.add(m)
}
period.months = [...active].sort((a, b) => a - b)
}
function toggleMonth(mi) {
const idx = period.months.indexOf(mi)
if (idx >= 0) period.months.splice(idx, 1)
else period.months.push(mi)
period.months.sort((a, b) => a - b)
syncQuartersFromMonths()
}
function syncQuartersFromMonths() {
const newQ = []
for (const [q, ms] of Object.entries(qMonths)) {
if (ms.every(m => period.months.includes(m))) newQ.push(q)
}
period.quarters = newQ
}
function isMonthActive(mi) {
return period.months.includes(mi)
}
const kpis = computed(() => [
{ key: "revenue", label: "Выручка", value: D.kpi.revenue.value, change: D.kpi.revenue.change, up: true, color: "green" },
{ key: "taxload", label: "Нал. нагрузка", value: D.kpi.taxload.value, change: D.kpi.taxload.change, up: false, color: "yellow" },
{ key: "employees", label: "Сотрудники", value: D.kpi.employees.value, change: D.kpi.employees.change, up: true, color: "blue" },
{ key: "declarations", label: "Декларации", value: D.kpi.declarations.value, change: D.kpi.declarations.change, up: false, color: "yellow" },
{ key: "inspections", label: "Проверки", value: D.kpi.inspections.value, change: D.kpi.inspections.change, up: false, color: "red", valueStyle: "color: var(--ins-warning)" },
])
const taxBars = computed(() =>
D.taxStructure.map((t) => ({
name: t.name, value: t.value, pct: t.pct, color: t.color,
widthPct: (t.pct / 31.5) * 82,
}))
)
const dateRange = computed(() => {
if (period.months.length === 0) return `01.01.${period.year} 31.12.${period.year}`
const first = period.months[0]
const last = period.months[period.months.length - 1]
const fDay = String(first + 1).padStart(2, "0")
const lastDays = [31,28,31,30,31,30,31,31,30,31,30,31]
const lDay = lastDays[last]
const lMonth = String(last + 1).padStart(2, "0")
return `01.${fDay}.${period.year} ${lDay}.${lMonth}.${period.year}`
})
function doInspect() {
popoverOpen.value = false
loading.value = true
setTimeout(() => {
loading.value = false
inspected.value = true
}, 1200)
}
function onOpenDetail(ctx, key) {
detailData.value = D.pulse[key] || D[ctx]?.[key] || null
if (detailData.value) openDetail()
}
function barHeight(val, max) {
return (val / max) * 100
}
function handleClickOutside(e) {
if (popoverOpen.value) popoverOpen.value = false
}
onMounted(() => document.addEventListener("click", handleClickOutside))
onUnmounted(() => document.removeEventListener("click", handleClickOutside))
</script>

View File

@ -0,0 +1,73 @@
<template>
<div class="animate-fade-slide-in">
<FilterBar
title="КАТЕГОРИИ"
:groups="filterGroups"
v-model="filters"
/>
<div class="content-swap mb-5" :class="cardsClass">
<div class="grid grid-cols-4 gap-3">
<KpiCard
v-for="card in visibleCards"
:key="card.key"
v-bind="card"
:change-up="true"
:small-value="true"
@click="onCardClick(card.key)"
/>
</div>
</div>
<DetailPanel
:open="detailOpen"
:title="detailData?.title || ''"
:columns="detailData?.head || []"
:rows="detailData?.rows || []"
:summary="detailData?.sum || ''"
@close="closeDetail"
/>
</div>
</template>
<script setup>
import { ref, computed } from "vue"
import FilterBar from "@/components/shared/FilterBar.vue"
import KpiCard from "@/components/shared/KpiCard.vue"
import DetailPanel from "@/components/shared/DetailPanel.vue"
import { useStore } from "@/data/store"
import { useContentSwap } from "@/utils/useContentSwap"
const { D } = useStore()
const { cardsClass, detailOpen, openDetail, closeDetail } = useContentSwap()
const filters = ref({})
const detailData = ref(null)
const filterGroups = [
{
chips: [
{ key: "rates", label: "Ставки" },
{ key: "social", label: "Взносы" },
{ key: "limits", label: "Лимиты" },
{ key: "inspection", label: "Инспекция" },
],
},
]
const allCards = [
{ key: "rates", label: "Налоговые ставки", value: "ВКРП", subtitle: "9 налогов · ставки 2026", change: "📊", status: "normal" },
{ key: "social", label: "Соц. взносы", value: "DSMF·İTS", subtitle: "3 вида · работодатель + работник", change: "💰", status: "normal" },
{ key: "limits", label: "Пороги и лимиты", value: "₼400", subtitle: "МЗП · ƏDV порог · льготы", change: "📋", status: "normal" },
{ key: "inspection", label: "Инспекция", value: "Risk", subtitle: "пороги · дедлайны · частота", change: "🔍", status: "normal" },
]
const visibleCards = computed(() =>
allCards.filter((c) => filters.value[c.key] !== false)
)
function onCardClick(key) {
detailData.value = D.region[key] || null
if (detailData.value) openDetail()
}
</script>

View File

@ -0,0 +1,101 @@
<template>
<div class="animate-fade-slide-in">
<FilterBar
title="ВКРП"
:groups="filterGroups"
v-model="filters"
/>
<div class="content-swap mb-5" :class="cardsClass">
<div
class="grid gap-3"
style="grid-template-columns: repeat(auto-fill, minmax(165px, 1fr))"
>
<KpiCard
v-for="card in visibleCards"
:key="card.key"
v-bind="card"
@click="onCardClick(card.key)"
/>
</div>
</div>
<DetailPanel
:open="detailOpen"
:title="detailData?.title || ''"
:badge="detailData?.badge || ''"
:columns="detailData?.head || []"
:rows="detailData?.rows || []"
:summary="detailData?.sum || ''"
@close="closeDetail"
/>
</div>
</template>
<script setup>
import { ref, computed } from "vue"
import FilterBar from "@/components/shared/FilterBar.vue"
import KpiCard from "@/components/shared/KpiCard.vue"
import DetailPanel from "@/components/shared/DetailPanel.vue"
import { useStore } from "@/data/store"
import { useContentSwap } from "@/utils/useContentSwap"
const { D } = useStore()
const { cardsClass, detailOpen, openDetail, closeDetail } = useContentSwap()
const filters = ref({})
const detailData = ref(null)
const filterGroups = [
{
chips: [
{ key: "edv", label: "ƏDV" },
{ key: "menfeet", label: "Mənfəət" },
{ key: "gelir", label: "Gəlir" },
{ key: "sadelesdirilmis", label: "Sadələşd." },
{ key: "aksiz", label: "Aksiz" },
{ key: "yol", label: "Yol" },
{ key: "meden", label: "Mədən" },
{ key: "emlak", label: "Əmlak" },
{ key: "torpaq", label: "Torpaq" },
],
},
{
separator: true, label: "Соц.",
chips: [
{ key: "dsmf", label: "DSMF" },
{ key: "its", label: "İTS" },
{ key: "issizlik", label: "İşsizlik" },
],
},
{
separator: true, label: "Удерж.",
chips: [{ key: "menbede", label: "Mənbədə" }],
},
]
const allCards = [
{ key: "edv", label: "ƏDV (НДС)", value: "₼5.8M", subtitle: "68 проверок", change: "+15%", changeUp: true, status: "normal" },
{ key: "menfeet", label: "Mənfəət vergisi", value: "₼4.2M", subtitle: "52 проверки", change: "+9%", changeUp: true, status: "normal" },
{ key: "gelir", label: "Gəlir vergisi", value: "₼3.1M", subtitle: "41 проверка", change: "-3%", changeUp: false, status: "normal" },
{ key: "sadelesdirilmis", label: "Sadələşdirilmiş", value: "₼0.5M", subtitle: "12 пров.", change: "+4%", changeUp: true, status: "normal" },
{ key: "aksiz", label: "Aksiz", value: "₼0.6M", subtitle: "8 пров.", change: "-7%", changeUp: false, status: "normal" },
{ key: "yol", label: "Yol", value: "₼0.2M", subtitle: "5 пров.", change: "+2%", changeUp: true, status: "normal" },
{ key: "meden", label: "Mədən", value: "₼0.1M", subtitle: "3 пров.", change: "+1%", changeUp: true, status: "normal" },
{ key: "emlak", label: "Əmlak", value: "₼0.4M", subtitle: "9 пров.", change: "-2%", changeUp: false, status: "normal" },
{ key: "torpaq", label: "Torpaq", value: "₼0.3M", subtitle: "6 пров.", change: "+3%", changeUp: true, status: "normal" },
{ key: "dsmf", label: "DSMF", value: "₼1.5M", subtitle: "28 пров.", change: "+6%", changeUp: true, status: "normal" },
{ key: "its", label: "İTS", value: "₼0.9M", subtitle: "18 пров.", change: "+5%", changeUp: true, status: "normal" },
{ key: "issizlik", label: "İşsizlik", value: "₼0.7M", subtitle: "14 пров.", change: "+3%", changeUp: true, status: "normal" },
{ key: "menbede", label: "Mənbədə tutulan", value: "₼1.9M", subtitle: "48 пров.", change: "+18%", changeUp: true, status: "critical" },
]
const visibleCards = computed(() =>
allCards.filter((c) => filters.value[c.key] !== false)
)
function onCardClick(key) {
detailData.value = D.tax[key] || null
if (detailData.value) openDetail()
}
</script>

View File

@ -0,0 +1,40 @@
import { createRouter, createWebHistory } from "vue-router"
const routes = [
{
path: "/",
redirect: "/pulse",
},
{
path: "/pulse",
name: "Pulse",
component: () => import("@/pages/PulsePage.vue"),
},
{
path: "/inspection",
name: "Inspection",
component: () => import("@/pages/InspectionPage.vue"),
},
{
path: "/tax-types",
name: "TaxTypes",
component: () => import("@/pages/TaxTypesPage.vue"),
},
{
path: "/accounting",
name: "Accounting",
component: () => import("@/pages/AccountingPage.vue"),
},
{
path: "/settings",
name: "Settings",
component: () => import("@/pages/SettingsPage.vue"),
},
]
const router = createRouter({
history: createWebHistory("/inspector"),
routes,
})
export default router

View File

@ -0,0 +1,37 @@
import { ref, nextTick } from "vue"
export function useContentSwap() {
const cardsClass = ref("")
const detailOpen = ref(false)
function openDetail() {
cardsClass.value = "fading-out"
setTimeout(() => {
cardsClass.value = "gone"
detailOpen.value = true
}, 350)
}
function closeDetail() {
// DetailPanel already played its leave animation (450ms)
// and emitted 'close'. Now bring cards back.
detailOpen.value = false
nextTick(() => {
// Start invisible, shifted down
cardsClass.value = "fading-in"
// After browser renders invisible state, start reveal transition
setTimeout(() => {
cardsClass.value = "revealing"
}, 50)
// Clean up after transition
setTimeout(() => {
cardsClass.value = ""
}, 700)
})
}
return { cardsClass, detailOpen, openDetail, closeDetail }
}

View File

@ -0,0 +1,30 @@
import frappeUIPreset from "frappe-ui/src/tailwind/preset"
export default {
presets: [frappeUIPreset],
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
"./node_modules/frappe-ui/src/components/**/*.{vue,js,ts,jsx,tsx}",
"../node_modules/frappe-ui/src/components/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
"ins-indigo": "#4f46e5",
"ins-positive": "#059669",
"ins-negative": "#dc2626",
"ins-warning": "#d97706",
"ins-info": "#2563eb",
},
fontFamily: {
manrope: ["Manrope", "sans-serif"],
mono: ["Source Code Pro", "monospace"],
},
maxWidth: {
content: "1480px",
},
},
},
plugins: [],
}

79
frontend/vite.config.js Normal file
View File

@ -0,0 +1,79 @@
import { defineConfig } from "vite"
import vue from "@vitejs/plugin-vue"
import frappeui from "frappe-ui/vite"
import path from "path"
import fs from "fs"
export default defineConfig({
server: {
port: 8081,
proxy: getProxyOptions(),
allowedHosts: true,
},
plugins: [vue(), frappeui()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
build: {
outDir: "../inspector/public/frontend",
emptyOutDir: true,
target: "es2015",
commonjsOptions: {
include: [/tailwind.config.js/, /node_modules/],
},
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
"frappe-ui": ["frappe-ui"],
},
},
},
},
optimizeDeps: {
include: [
"frappe-ui > feather-icons",
"showdown",
"tailwind.config.js",
"engine.io-client",
],
},
})
function getProxyOptions() {
const config = getCommonSiteConfig()
const webserver_port = config ? config.webserver_port : 8000
if (!config) {
console.log("No common_site_config.json found, using default port 8000")
}
return {
"^/(app|login|api|assets|files|private)": {
target: `http://127.0.0.1:${webserver_port}`,
ws: true,
router: function (req) {
const site_name = req.headers.host.split(":")[0]
return `http://${site_name}:${webserver_port}`
},
},
}
}
function getCommonSiteConfig() {
let currentDir = path.resolve(".")
while (currentDir !== "/") {
if (
fs.existsSync(path.join(currentDir, "sites")) &&
fs.existsSync(path.join(currentDir, "apps"))
) {
let configPath = path.join(currentDir, "sites", "common_site_config.json")
if (fs.existsSync(configPath)) {
return JSON.parse(fs.readFileSync(configPath))
}
return null
}
currentDir = path.resolve(currentDir, "..")
}
return null
}

1
inspector/__init__.py Normal file
View File

@ -0,0 +1 @@
__version__ = "0.0.1"

View File

24
inspector/hooks.py Normal file
View File

@ -0,0 +1,24 @@
app_name = "inspector"
app_title = "Inspector"
app_publisher = "Jey Soft LLC"
app_description = "Inspector - Tax Audit Dashboard"
app_email = "inspector@jeysoft.az"
app_license = "mit"
required_apps = ["frappe"]
app_logo_url = "/assets/inspector/images/inspector-logo.svg"
add_to_apps_screen = [
{
"name": "inspector",
"logo": "/assets/inspector/images/inspector-logo.svg",
"title": "Inspector",
"route": "/inspector",
}
]
website_route_rules = [
{"from_route": "/inspector/<path:app_path>", "to_route": "inspector"},
]

View File

View File

View File

@ -0,0 +1,69 @@
{
"actions": [],
"autoname": "format:{tax_type}-{frequency}",
"creation": "2026-03-30 00:00:00.000000",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"tax_type",
"frequency",
"day_of_month",
"description"
],
"fields": [
{
"fieldname": "tax_type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Tax Type",
"options": "\nÆDV\nMÉnfÉÉt\nGÉlir\nSadÉlÉÅdirilmiÅ\nAksiz\nYol\nMÉdÉn\nÆmlak\nTorpaq\nDSMF\nİTS\nİÅsizlik\nMÉnbÉdÉ",
"reqd": 1
},
{
"fieldname": "frequency",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Frequency",
"options": "Monthly\nQuarterly\nAnnual",
"reqd": 1
},
{
"fieldname": "day_of_month",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Day of Month",
"description": "Day of month when payment/filing is due"
},
{
"fieldname": "description",
"fieldtype": "Small Text",
"label": "Description"
}
],
"index_web_pages_for_search": 0,
"links": [],
"modified": "2026-03-30 00:00:00.000000",
"modified_by": "Administrator",
"module": "Inspector",
"name": "Tax Deadline",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "tax_type"
}

View File

@ -0,0 +1,6 @@
import frappe
from frappe.model.document import Document
class TaxDeadline(Document):
pass

View File

@ -0,0 +1,103 @@
{
"actions": [],
"autoname": "format:DEC-{#####}",
"creation": "2026-03-30 00:00:00.000000",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"company",
"tax_type",
"period",
"column_break_1",
"filing_date",
"due_date",
"status",
"section_break_1",
"amount"
],
"fields": [
{
"fieldname": "company",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Company",
"options": "Company",
"reqd": 1
},
{
"fieldname": "tax_type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Tax Type",
"options": "\nÆDV\nMÉnfÉÉt\nGÉlir\nSadÉlÉÅdirilmiÅ\nAksiz\nYol\nMÉdÉn\nÆmlak\nTorpaq\nDSMF\nİTS\nİÅsizlik\nMÉnbÉdÉ",
"reqd": 1
},
{
"fieldname": "period",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Period",
"description": "e.g. 2025-Q1, 2025-03"
},
{
"fieldname": "column_break_1",
"fieldtype": "Column Break"
},
{
"fieldname": "filing_date",
"fieldtype": "Date",
"label": "Filing Date"
},
{
"fieldname": "due_date",
"fieldtype": "Date",
"in_list_view": 1,
"label": "Due Date",
"reqd": 1
},
{
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Status",
"options": "Draft\nFiled\nAccepted\nLate",
"default": "Draft"
},
{
"fieldname": "section_break_1",
"fieldtype": "Section Break",
"label": "Amount"
},
{
"fieldname": "amount",
"fieldtype": "Currency",
"label": "Amount"
}
],
"index_web_pages_for_search": 0,
"links": [],
"modified": "2026-03-30 00:00:00.000000",
"modified_by": "Administrator",
"module": "Inspector",
"name": "Tax Declaration",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "tax_type"
}

View File

@ -0,0 +1,6 @@
import frappe
from frappe.model.document import Document
class TaxDeclaration(Document):
pass

View File

@ -0,0 +1,127 @@
{
"actions": [],
"autoname": "format:INS-{#####}",
"creation": "2026-03-30 00:00:00.000000",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"company",
"tax_type",
"voen",
"column_break_1",
"date_opened",
"date_closed",
"status",
"section_break_1",
"risk_score",
"penalty_amount",
"saved_amount",
"section_break_2",
"notes"
],
"fields": [
{
"fieldname": "company",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Company",
"options": "Company",
"reqd": 1
},
{
"fieldname": "tax_type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Tax Type",
"options": "\nÆDV\nMÉnfÉÉt\nGÉlir\nSadÉlÉÅdirilmiÅ\nAksiz\nYol\nMÉdÉn\nÆmlak\nTorpaq\nDSMF\nİTS\nİÅsizlik\nMÉnbÉdÉ",
"reqd": 1
},
{
"fieldname": "voen",
"fieldtype": "Data",
"in_list_view": 1,
"label": "VÖEN"
},
{
"fieldname": "column_break_1",
"fieldtype": "Column Break"
},
{
"fieldname": "date_opened",
"fieldtype": "Date",
"in_list_view": 1,
"label": "Date Opened",
"reqd": 1
},
{
"fieldname": "date_closed",
"fieldtype": "Date",
"label": "Date Closed"
},
{
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Status",
"options": "Open\nIn Progress\nClosed",
"default": "Open"
},
{
"fieldname": "section_break_1",
"fieldtype": "Section Break",
"label": "Scores & Amounts"
},
{
"fieldname": "risk_score",
"fieldtype": "Int",
"label": "Risk Score",
"description": "0-100"
},
{
"fieldname": "penalty_amount",
"fieldtype": "Currency",
"label": "Penalty Amount"
},
{
"fieldname": "saved_amount",
"fieldtype": "Currency",
"label": "Saved Amount"
},
{
"fieldname": "section_break_2",
"fieldtype": "Section Break",
"label": "Notes"
},
{
"fieldname": "notes",
"fieldtype": "Text",
"label": "Notes"
}
],
"index_web_pages_for_search": 0,
"links": [],
"modified": "2026-03-30 00:00:00.000000",
"modified_by": "Administrator",
"module": "Inspector",
"name": "Tax Inspection",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "tax_type"
}

View File

@ -0,0 +1,6 @@
import frappe
from frappe.model.document import Document
class TaxInspection(Document):
pass

View File

@ -0,0 +1,88 @@
{
"actions": [],
"autoname": "format:{company}-{setting_key}",
"creation": "2026-03-30 00:00:00.000000",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"company",
"category",
"setting_key",
"column_break_1",
"setting_value",
"legal_reference",
"effective_date"
],
"fields": [
{
"fieldname": "company",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Company",
"options": "Company",
"reqd": 1
},
{
"fieldname": "category",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Category",
"options": "rates\nsocial\nlimits\ninspection",
"reqd": 1
},
{
"fieldname": "setting_key",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Setting Key",
"reqd": 1,
"unique": 0
},
{
"fieldname": "column_break_1",
"fieldtype": "Column Break"
},
{
"fieldname": "setting_value",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Value"
},
{
"fieldname": "legal_reference",
"fieldtype": "Data",
"label": "Legal Reference"
},
{
"fieldname": "effective_date",
"fieldtype": "Date",
"label": "Effective Date"
}
],
"index_web_pages_for_search": 0,
"links": [],
"modified": "2026-03-30 00:00:00.000000",
"modified_by": "Administrator",
"module": "Inspector",
"name": "Tax Setting",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "setting_key"
}

View File

@ -0,0 +1,6 @@
import frappe
from frappe.model.document import Document
class TaxSetting(Document):
pass

1
inspector/modules.txt Normal file
View File

@ -0,0 +1 @@
Inspector

6
inspector/patches.txt Normal file
View File

@ -0,0 +1,6 @@
[pre_model_sync]
# Patches added in this section will be executed before doctypes are migrated
# Read docs to understand patches: https://frappeframework.com/docs/v14/user/en/database-migrations
[post_model_sync]
# Patches added in this section will be executed after doctypes are migrated

View File

View File

View File

@ -0,0 +1,10 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="14" fill="url(#g)"/>
<text x="32" y="38" text-anchor="middle" font-family="system-ui,-apple-system,sans-serif" font-weight="800" font-size="22" fill="#fff">JI</text>
<defs>
<linearGradient id="g" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
<stop stop-color="#4f46e5"/>
<stop offset="1" stop-color="#7c3aed"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 517 B

View File

View File

View File

View File

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Inspector</title>
<link
href="https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;600;700;800&family=Source+Code+Pro:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<script type="module" crossorigin src="/assets/inspector/frontend/assets/index-DUszJK-2.js"></script>
<link rel="modulepreload" crossorigin href="/assets/inspector/frontend/assets/frappe-ui-D3iQUq0I.js">
<link rel="stylesheet" crossorigin href="/assets/inspector/frontend/assets/frappe-ui-D1rk2b8Q.css">
<link rel="stylesheet" crossorigin href="/assets/inspector/frontend/assets/index-B1mkOpgm.css">
</head>
<body class="antialiased">
<div id="app"></div>
<div id="modals"></div>
<div id="popovers"></div>
<script>
window.csrf_token = "{{ csrf_token }}"
window.site_name = "{{ site_name }}"
if (!window.frappe) window.frappe = {}
frappe.boot = {{ boot }}
</script>
</body>
</html>

View File

@ -0,0 +1,28 @@
import frappe
no_cache = 1
def get_context(context):
csrf_token = frappe.sessions.get_csrf_token()
frappe.db.commit() # nosemgrep
context = frappe._dict()
context.csrf_token = csrf_token
context.boot = get_boot()
return context
@frappe.whitelist(methods=["POST"], allow_guest=True)
def get_context_for_dev():
if not frappe.conf.developer_mode:
frappe.throw(frappe._("This method is only meant for developer mode"))
return get_boot()
def get_boot():
return frappe._dict(
{
"site_name": frappe.local.site,
"default_route": "/inspector",
}
)

21
license.txt Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) [year] [fullname]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

65
pyproject.toml Normal file
View File

@ -0,0 +1,65 @@
[project]
name = "inspector"
authors = [
{ name = "Jey Soft LLC", email = "inspector@jeysoft.az"}
]
description = "Inspector - Tax Audit Dashboard"
requires-python = ">=3.14"
readme = "README.md"
dynamic = ["version"]
dependencies = [
# "frappe~=16.0.0" # Installed and managed by bench.
]
[build-system]
requires = ["flit_core >=3.4,<4"]
build-backend = "flit_core.buildapi"
# These dependencies are only installed when developer mode is enabled
[tool.bench.dev-dependencies]
# package_name = "~=1.1.0"
# These apt dependencies will be installed from Ubuntu repositories when you host your app on Frappe Cloud
[deploy.dependencies.apt]
packages = []
[tool.ruff]
line-length = 110
target-version = "py314"
[tool.ruff.lint]
select = [
"F",
"E",
"W",
"I",
"UP",
"B",
"RUF",
]
ignore = [
"B017", # assertRaises(Exception) - should be more specific
"B018", # useless expression, not assigned to anything
"B023", # function doesn't bind loop variable - will have last iteration's value
"B904", # raise inside except without from
"E101", # indentation contains mixed spaces and tabs
"E402", # module level import not at top of file
"E501", # line too long
"E741", # ambiguous variable name
"F401", # "unused" imports
"F403", # can't detect undefined names from * import
"F405", # can't detect undefined names from * import
"F722", # syntax error in forward type annotation
"W191", # indentation contains tabs
"UP030", # Use implicit references for positional format fields (translations)
"UP031", # Use format specifiers instead of percent format
"UP032", # Use f-string instead of `format` call (translations)
"UP037", # quoted annotations
"UP040", # Use type aliases instead of type annotations
]
typing-modules = ["frappe.types.DF"]
[tool.ruff.format]
quote-style = "double"
indent-style = "tab"
docstring-code-format = true