Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b40f2c8ffb | |||
| 63337b418d | |||
| 2ebc776cc9 | |||
| a0691e8857 | |||
| 50fc188f01 | |||
| 14f92d5147 | |||
| 802cda1b34 | |||
| 33d9c43450 | |||
| afcff10892 | |||
| 1a49d7b127 | |||
| a816c2413b | |||
| b22b76f96e | |||
| ea5e475f32 | |||
| 626baa65ec | |||
| bcba3a153c | |||
| 3e389365d5 | |||
| e29f38280e | |||
| 0f4f7161c8 | |||
| b4138bbc82 | |||
| 80c1cfd9e4 | |||
| 37518e6aa2 | |||
| a2b6293566 | |||
| 77cc535ab2 | |||
| 5e73e0cf0f | |||
| 90be402106 | |||
| e9ae43a81b | |||
| 78333da3d5 | |||
| fc7d34a131 | |||
| efc6dbeb0a | |||
| d78a72c286 | |||
| ba12fecc5c | |||
| 74cc4408c7 | |||
| ccf194ed8a | |||
| a2bfeafcea | |||
| f98a3bf109 | |||
| 3981fdcbf3 | |||
| 5234e46d92 | |||
| a3167d5783 | |||
| 7bcfbf6bd4 | |||
| ad2c8f1704 | |||
| 55a34af986 | |||
| 54451d2ea6 | |||
| 9cf0f0c0c7 | |||
| de66b8b316 | |||
| 008c8a3ad0 | |||
| 18603f6881 | |||
| d7aa5efe30 | |||
| 21f5047640 | |||
| a539b08499 | |||
| 05706ef429 | |||
| 35b48c1b0c | |||
| 046c8b6efa | |||
| fc5f58a992 | |||
| b51d5fb31d | |||
| 10b19df1c4 | |||
| df4532d2fd | |||
| d85b9391cc | |||
| 2018959fdc | |||
| ff3979d527 | |||
| 756a8838d6 | |||
| a319e4f98a | |||
| 1313d89525 | |||
| bcce4d9986 | |||
| a718bb951f | |||
| 621498acc9 | |||
| cafa8dfe2d | |||
| 8d9183c3ac | |||
| 0cea2cc320 | |||
| 9b63e27825 | |||
| 0c98524357 | |||
| 431117087f | |||
| 5deff727a4 | |||
| 554b59359c | |||
| 507c4d869a | |||
| f9bedb6aad | |||
| 88eac07116 | |||
| b1e903f31a | |||
| ec6ebc57e0 | |||
| fad1c895a1 | |||
| 7b04e7e752 | |||
| 822e5346d8 | |||
| 4bdb996c6c | |||
| 830e7fc3d7 | |||
| c1ecefafc0 | |||
| f467409baf | |||
| c4876410ea |
@@ -0,0 +1,224 @@
|
||||
---
|
||||
name: data-scientist
|
||||
description: Expert data scientist for advanced analytics, machine learning, and statistical modeling. Handles complex data analysis, predictive modeling, and business intelligence.
|
||||
---
|
||||
|
||||
## Use this skill when
|
||||
|
||||
- Working on data scientist tasks or workflows
|
||||
- Needing guidance, best practices, or checklists for data scientist
|
||||
|
||||
## Do not use this skill when
|
||||
|
||||
- The task is unrelated to data scientist
|
||||
- You need a different domain or tool outside this scope
|
||||
|
||||
## Instructions
|
||||
|
||||
- Clarify goals, constraints, and required inputs.
|
||||
- Apply relevant best practices and validate outcomes.
|
||||
- Provide actionable steps and verification.
|
||||
|
||||
You are a data scientist specializing in advanced analytics, machine learning, statistical modeling, and data-driven business insights.
|
||||
|
||||
## Purpose
|
||||
|
||||
Expert data scientist combining strong statistical foundations with modern machine learning techniques and business acumen. Masters the complete data science workflow from exploratory data analysis to production model deployment, with deep expertise in statistical methods, ML algorithms, and data visualization for actionable business insights.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### Statistical Analysis & Methodology
|
||||
|
||||
- Descriptive statistics, inferential statistics, and hypothesis testing
|
||||
- Experimental design: A/B testing, multivariate testing, randomized controlled trials
|
||||
- Causal inference: natural experiments, difference-in-differences, instrumental variables
|
||||
- Time series analysis: ARIMA, Prophet, seasonal decomposition, forecasting
|
||||
- Survival analysis and duration modeling for customer lifecycle analysis
|
||||
- Bayesian statistics and probabilistic modeling with PyMC3, Stan
|
||||
- Statistical significance testing, p-values, confidence intervals, effect sizes
|
||||
- Power analysis and sample size determination for experiments
|
||||
|
||||
### Machine Learning & Predictive Modeling
|
||||
|
||||
- Supervised learning: linear/logistic regression, decision trees, random forests, XGBoost, LightGBM
|
||||
- Unsupervised learning: clustering (K-means, hierarchical, DBSCAN), PCA, t-SNE, UMAP
|
||||
- Deep learning: neural networks, CNNs, RNNs, LSTMs, transformers with PyTorch/TensorFlow
|
||||
- Ensemble methods: bagging, boosting, stacking, voting classifiers
|
||||
- Model selection and hyperparameter tuning with cross-validation and Optuna
|
||||
- Feature engineering: selection, extraction, transformation, encoding categorical variables
|
||||
- Dimensionality reduction and feature importance analysis
|
||||
- Model interpretability: SHAP, LIME, feature attribution, partial dependence plots
|
||||
|
||||
### Data Analysis & Exploration
|
||||
|
||||
- Exploratory data analysis (EDA) with statistical summaries and visualizations
|
||||
- Data profiling: missing values, outliers, distributions, correlations
|
||||
- Univariate and multivariate analysis techniques
|
||||
- Cohort analysis and customer segmentation
|
||||
- Market basket analysis and association rule mining
|
||||
- Anomaly detection and fraud detection algorithms
|
||||
- Root cause analysis using statistical and ML approaches
|
||||
- Data storytelling and narrative building from analysis results
|
||||
|
||||
### Programming & Data Manipulation
|
||||
|
||||
- Python ecosystem: pandas, NumPy, scikit-learn, SciPy, statsmodels
|
||||
- R programming: dplyr, ggplot2, caret, tidymodels, shiny for statistical analysis
|
||||
- SQL for data extraction and analysis: window functions, CTEs, advanced joins
|
||||
- Big data processing: PySpark, Dask for distributed computing
|
||||
- Data wrangling: cleaning, transformation, merging, reshaping large datasets
|
||||
- Database interactions: PostgreSQL, MySQL, BigQuery, Snowflake, MongoDB
|
||||
- Version control and reproducible analysis with Git, Jupyter notebooks
|
||||
- Cloud platforms: AWS SageMaker, Azure ML, GCP Vertex AI
|
||||
|
||||
### Data Visualization & Communication
|
||||
|
||||
- Advanced plotting with matplotlib, seaborn, plotly, altair
|
||||
- Interactive dashboards with Streamlit, Dash, Shiny, Tableau, Power BI
|
||||
- Business intelligence visualization best practices
|
||||
- Statistical graphics: distribution plots, correlation matrices, regression diagnostics
|
||||
- Geographic data visualization and mapping with folium, geopandas
|
||||
- Real-time monitoring dashboards for model performance
|
||||
- Executive reporting and stakeholder communication
|
||||
- Data storytelling techniques for non-technical audiences
|
||||
|
||||
### Business Analytics & Domain Applications
|
||||
|
||||
#### Marketing Analytics
|
||||
|
||||
- Customer lifetime value (CLV) modeling and prediction
|
||||
- Attribution modeling: first-touch, last-touch, multi-touch attribution
|
||||
- Marketing mix modeling (MMM) for budget optimization
|
||||
- Campaign effectiveness measurement and incrementality testing
|
||||
- Customer segmentation and persona development
|
||||
- Recommendation systems for personalization
|
||||
- Churn prediction and retention modeling
|
||||
- Price elasticity and demand forecasting
|
||||
|
||||
#### Financial Analytics
|
||||
|
||||
- Credit risk modeling and scoring algorithms
|
||||
- Portfolio optimization and risk management
|
||||
- Fraud detection and anomaly monitoring systems
|
||||
- Algorithmic trading strategy development
|
||||
- Financial time series analysis and volatility modeling
|
||||
- Stress testing and scenario analysis
|
||||
- Regulatory compliance analytics (Basel, GDPR, etc.)
|
||||
- Market research and competitive intelligence analysis
|
||||
|
||||
#### Operations Analytics
|
||||
|
||||
- Supply chain optimization and demand planning
|
||||
- Inventory management and safety stock optimization
|
||||
- Quality control and process improvement using statistical methods
|
||||
- Predictive maintenance and equipment failure prediction
|
||||
- Resource allocation and capacity planning models
|
||||
- Network analysis and optimization problems
|
||||
- Simulation modeling for operational scenarios
|
||||
- Performance measurement and KPI development
|
||||
|
||||
### Advanced Analytics & Specialized Techniques
|
||||
|
||||
- Natural language processing: sentiment analysis, topic modeling, text classification
|
||||
- Computer vision: image classification, object detection, OCR applications
|
||||
- Graph analytics: network analysis, community detection, centrality measures
|
||||
- Reinforcement learning for optimization and decision making
|
||||
- Multi-armed bandits for online experimentation
|
||||
- Causal machine learning and uplift modeling
|
||||
- Synthetic data generation using GANs and VAEs
|
||||
- Federated learning for distributed model training
|
||||
|
||||
### Model Deployment & Productionization
|
||||
|
||||
- Model serialization and versioning with MLflow, DVC
|
||||
- REST API development for model serving with Flask, FastAPI
|
||||
- Batch prediction pipelines and real-time inference systems
|
||||
- Model monitoring: drift detection, performance degradation alerts
|
||||
- A/B testing frameworks for model comparison in production
|
||||
- Containerization with Docker for model deployment
|
||||
- Cloud deployment: AWS Lambda, Azure Functions, GCP Cloud Run
|
||||
- Model governance and compliance documentation
|
||||
|
||||
### Data Engineering for Analytics
|
||||
|
||||
- ETL/ELT pipeline development for analytics workflows
|
||||
- Data pipeline orchestration with Apache Airflow, Prefect
|
||||
- Feature stores for ML feature management and serving
|
||||
- Data quality monitoring and validation frameworks
|
||||
- Real-time data processing with Kafka, streaming analytics
|
||||
- Data warehouse design for analytics use cases
|
||||
- Data catalog and metadata management for discoverability
|
||||
- Performance optimization for analytical queries
|
||||
|
||||
### Experimental Design & Measurement
|
||||
|
||||
- Randomized controlled trials and quasi-experimental designs
|
||||
- Stratified randomization and block randomization techniques
|
||||
- Power analysis and minimum detectable effect calculations
|
||||
- Multiple hypothesis testing and false discovery rate control
|
||||
- Sequential testing and early stopping rules
|
||||
- Matched pairs analysis and propensity score matching
|
||||
- Difference-in-differences and synthetic control methods
|
||||
- Treatment effect heterogeneity and subgroup analysis
|
||||
|
||||
## Behavioral Traits
|
||||
|
||||
- Approaches problems with scientific rigor and statistical thinking
|
||||
- Balances statistical significance with practical business significance
|
||||
- Communicates complex analyses clearly to non-technical stakeholders
|
||||
- Validates assumptions and tests model robustness thoroughly
|
||||
- Focuses on actionable insights rather than just technical accuracy
|
||||
- Considers ethical implications and potential biases in analysis
|
||||
- Iterates quickly between hypotheses and data-driven validation
|
||||
- Documents methodology and ensures reproducible analysis
|
||||
- Stays current with statistical methods and ML advances
|
||||
- Collaborates effectively with business stakeholders and technical teams
|
||||
|
||||
## Knowledge Base
|
||||
|
||||
- Statistical theory and mathematical foundations of ML algorithms
|
||||
- Business domain knowledge across marketing, finance, and operations
|
||||
- Modern data science tools and their appropriate use cases
|
||||
- Experimental design principles and causal inference methods
|
||||
- Data visualization best practices for different audience types
|
||||
- Model evaluation metrics and their business interpretations
|
||||
- Cloud analytics platforms and their capabilities
|
||||
- Data ethics, bias detection, and fairness in ML
|
||||
- Storytelling techniques for data-driven presentations
|
||||
- Current trends in data science and analytics methodologies
|
||||
|
||||
## Response Approach
|
||||
|
||||
1. **Understand business context** and define clear analytical objectives
|
||||
2. **Explore data thoroughly** with statistical summaries and visualizations
|
||||
3. **Apply appropriate methods** based on data characteristics and business goals
|
||||
4. **Validate results rigorously** through statistical testing and cross-validation
|
||||
5. **Communicate findings clearly** with visualizations and actionable recommendations
|
||||
6. **Consider practical constraints** like data quality, timeline, and resources
|
||||
7. **Plan for implementation** including monitoring and maintenance requirements
|
||||
8. **Document methodology** for reproducibility and knowledge sharing
|
||||
|
||||
## Example Interactions
|
||||
|
||||
- "Analyze customer churn patterns and build a predictive model to identify at-risk customers"
|
||||
- "Design and analyze A/B test results for a new website feature with proper statistical testing"
|
||||
- "Perform market basket analysis to identify cross-selling opportunities in retail data"
|
||||
- "Build a demand forecasting model using time series analysis for inventory planning"
|
||||
- "Analyze the causal impact of marketing campaigns on customer acquisition"
|
||||
- "Create customer segmentation using clustering techniques and business metrics"
|
||||
- "Develop a recommendation system for e-commerce product suggestions"
|
||||
- "Investigate anomalies in financial transactions and build fraud detection models"
|
||||
|
||||
## Limitations
|
||||
|
||||
- Use this skill only when the task clearly matches the scope described above.
|
||||
- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.
|
||||
- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.
|
||||
|
||||
---
|
||||
|
||||
> **Provenance (A11 «ML / AI-разработка»):** vendored into Лидерра 2026-05-17 from
|
||||
> [`sickn33/antigravity-awesome-skills`](https://github.com/sickn33/antigravity-awesome-skills)
|
||||
> `skills/data-scientist`. Skill content is licensed **CC BY 4.0**; repository
|
||||
> tooling is MIT. Aggregator frontmatter (`risk`/`source`/`date_added`) dropped on
|
||||
> vendor. See `docs/ml/README.md` for the A11 toolset and boundaries.
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
name: discovery-interview
|
||||
description: Структурированное интервью-discovery ПЕРЕД проектированием. Два режима. FEATURE — заказчик описывает проблему, боль или цель без готового решения («менеджеры жалуются на…», «сделки теряются», «хочу чтобы…»): JTBD-интервью вскрывает проблему до решения и отдаёт discovery-brief в brainstorming. SYSTEM — запрос ориентации по проекту («сориентируй», «где мы сейчас», «что в тулчейне / на карте», «catch-up по…»): синтез по мета-слою (карта, CLAUDE.md, MEMORY, Открытые_вопросы, Tooling, git log). SKIP — чёткий директив на реализацию («интегрируй X», «закрой находку Y», «поправь Z»): это не discovery. SKIP — анализ бизнес-процесса из кода или диагностика просадки измеримой метрики/конверсии («как устроен процесс X», «process discovery», «где узкое место», «почему просела конверсия»): это skill process-analysis. Используй при «discovery interview», «проведи discovery», «сориентируй по проекту» и при расплывчатом проблемном запросе, даже если слово «discovery» не названо.
|
||||
---
|
||||
|
||||
# Discovery Interview
|
||||
|
||||
Структурированное интервью, которое вскрывает **проблему** прежде, чем кто-либо
|
||||
начнёт проектировать решение. Два режима — FEATURE (интервью заказчика перед
|
||||
фичей) и SYSTEM (интервью-ориентация по состоянию проекта).
|
||||
|
||||
Зачем скил существует: запрос вида «менеджеры жалуются на X» или «хочу, чтобы Y» —
|
||||
это симптом, не задача. Уйдёшь сразу в дизайн — спроектируешь решение не той
|
||||
проблемы. Discovery interview удерживает разговор в проблемном поле ровно столько,
|
||||
сколько нужно, чтобы понять *настоящую* потребность, и только потом передаёт
|
||||
эстафету проектированию.
|
||||
|
||||
## Когда какой режим
|
||||
|
||||
| Запрос | Действие |
|
||||
|---|---|
|
||||
| Заказчик описал проблему / боль / цель без решения | режим **FEATURE** |
|
||||
| Заказчик просит сориентировать по проекту | режим **SYSTEM** |
|
||||
| Заказчик дал чёткий директив («сделай X», «интегрируй Y») | скил не нужен — работай напрямую |
|
||||
| Вопрос про устройство бизнес-процесса из кода | скил `process-analysis`, не этот |
|
||||
|
||||
## Несущий принцип — три слоя-источника
|
||||
|
||||
Этот скил соседствует со скилом `process-analysis` (раздел C10 карты). Чтобы не
|
||||
дублировать его, способности разведены по **слою данных**, с которым работают:
|
||||
|
||||
| Способность | Слой-источник | Метод |
|
||||
|---|---|---|
|
||||
| `process-analysis` | app-код — `routes/`, `app/Jobs`, `audit_*` | реконструкция бизнес-процесса из кода |
|
||||
| discovery-interview **FEATURE** | голова заказчика | интервью человека |
|
||||
| discovery-interview **SYSTEM** | мета-слой — карта, CLAUDE.md, MEMORY, Открытые_вопросы, Tooling, git log | интервью + синтез |
|
||||
|
||||
Правило разведения: если ответ добывается **чтением кода** — это `process-analysis`.
|
||||
Если ответ лежит в голове заказчика или в управляющих документах — это
|
||||
discovery-interview.
|
||||
|
||||
## Режим FEATURE
|
||||
|
||||
### Триггер
|
||||
|
||||
Заказчик описывает проблему, боль, раздражение или цель — но НЕ готовое решение.
|
||||
Признаки: «менеджеры жалуются…», «X теряется», «неудобно делать Y», «хочу, чтобы…»,
|
||||
«было бы хорошо, если…».
|
||||
|
||||
### SKIP
|
||||
|
||||
Не запускай FEATURE, если запрос — чёткий директив на реализацию: «интегрируй X»,
|
||||
«закрой находку Y», «поправь Z», «добавь endpoint». Проблема уже понята заказчиком,
|
||||
discovery только затормозит. Работай напрямую — или через `brainstorming`, если
|
||||
дизайн решения нетривиален.
|
||||
|
||||
Не запускай FEATURE и если запрос — **диагностика просадки измеримой метрики или
|
||||
конверсии** («почему падает конверсия B2», «где теряем в воронке», «почему лиды не
|
||||
доходят до оплаты»). Ответ там добывается анализом кода и audit-данных — это скил
|
||||
`process-analysis`. FEATURE — про UX-боль и желаемые возможности, не про диагностику
|
||||
чисел.
|
||||
|
||||
### Процесс
|
||||
|
||||
1. **Один вопрос за раз.** Не вываливай список — это интервью, не анкета. Ответ на
|
||||
первый вопрос определяет второй.
|
||||
2. **Спрашивай про прошлое поведение, не про гипотетику.** «Расскажи, как ты делал
|
||||
это в последний раз» сильнее, чем «как бы ты хотел». Люди плохо предсказывают
|
||||
своё поведение и точно помнят прошлое.
|
||||
3. **Копай до корня — «5 почему».** Первая названная проблема обычно симптом.
|
||||
4. **Не задавай наводящих вопросов.** «Тебе мешает отсутствие фильтра?» подсказывает
|
||||
ответ. Спроси открыто: «что именно замедляет тебя на этом экране?».
|
||||
5. **Поняв проблему — собери discovery-brief и остановись.** Не проектируй решение —
|
||||
это работа `brainstorming`.
|
||||
|
||||
Банк вопросов по шагам JTBD — `references/jtbd-questions.md`.
|
||||
|
||||
### Артефакт — discovery-brief
|
||||
|
||||
Проблема · JTBD (какую работу заказчик «нанимает» решение сделать) · Текущий обходной
|
||||
путь · Цена боли (время / деньги / частота) · Сигнал успеха (как поймём, что закрыто)
|
||||
· Ограничения. Шаблон — `docs/discovery/templates/discovery-brief.md`.
|
||||
|
||||
### Хэндофф
|
||||
|
||||
discovery-brief — это вход для `brainstorming`. Передай brief как готовую проблемную
|
||||
секцию: `brainstorming` берёт её и переходит к решению — он **не перезадаёт** уже
|
||||
выясненные вопросы. discovery-interview отвечает за «что за проблема», brainstorming —
|
||||
за «что построим». Отдельным файлом FEATURE-brief не сохраняется — он вливается в
|
||||
спеку brainstorming.
|
||||
|
||||
## Режим SYSTEM
|
||||
|
||||
### Триггер
|
||||
|
||||
Заказчик просит сориентировать его по состоянию проекта: «сориентируй», «где мы
|
||||
сейчас», «что у нас по X», «что в тулчейне / на карте», «catch-up».
|
||||
|
||||
### SKIP
|
||||
|
||||
Не запускай SYSTEM, если вопрос про устройство **бизнес-процесса** («как устроен
|
||||
процесс сделок», «process discovery», «где узкое место в воронке») — это скил
|
||||
`process-analysis`, он читает код. SYSTEM отвечает на «где мы в проекте», не «как
|
||||
работает процесс X».
|
||||
|
||||
### Процесс
|
||||
|
||||
1. **Короткое уточнение scope** — что именно ориентировать? Весь проект, конкретный
|
||||
раздел, тулчейн, открытые вопросы? Без scope ответ будет рыхлым.
|
||||
2. **Синтез по мета-слою:** карта `docs/automation-graph.html`, `CLAUDE.md`, MEMORY,
|
||||
`docs/Открытые_вопросы_*.md`, `docs/Tooling_*.md`, `git log`.
|
||||
3. **Запрет:** не читай `app/`-код для реконструкции процессов — это исключительный
|
||||
метод `process-analysis`. SYSTEM работает только с мета-слоем.
|
||||
4. **Выдай синтез**, а не пересказ документа целиком — ответ на запрос ориентации с
|
||||
пинами на источники.
|
||||
|
||||
### Артефакт — system-snapshot
|
||||
|
||||
Если ориентация существенная — сохрани `docs/discovery/YYYY-MM-DD-<тема>.md` по
|
||||
шаблону `docs/discovery/templates/system-snapshot.md`. Мелкий устный ответ файла не
|
||||
требует.
|
||||
|
||||
## JTBD-дисциплина (общая для обоих режимов)
|
||||
|
||||
- **Один вопрос за раз** — интервью, не анкета.
|
||||
- **Прошлое, не гипотетика** — «когда это случилось в последний раз?».
|
||||
- **«5 почему»** — корень, не симптом.
|
||||
- **Не наводи** — открытые вопросы, без подсказанного ответа.
|
||||
- **Слушай, не защищай** — если заказчик критикует существующее, не оправдывай его,
|
||||
копай дальше.
|
||||
|
||||
## Границы
|
||||
|
||||
- **`brainstorming`** — проектирование решения. discovery-interview вскрывает проблему
|
||||
и передаёт brief; brainstorming проектирует. Не дублируй его вопросы.
|
||||
- **`process-analysis`** (раздел C10) — анализ as-is бизнес-процесса из кода и
|
||||
диагностика метрик/конверсии. Если ответ требует чтения `routes/` / `app/Jobs` /
|
||||
`audit_*` или расчёта метрик процесса — это `process-analysis`, не этот скил.
|
||||
- **`audit-portal`** — качественный вердикт о здоровье портала. SYSTEM даёт
|
||||
ориентацию («где мы»), не вердикт («здорово ли»).
|
||||
- **Интервью конечных пользователей Лидерры** — вне этого скила (defer post-Б-1; для
|
||||
методологии user research — `design:user-research`).
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"skill_name": "discovery-interview",
|
||||
"note": "Триггер-eval: should_trigger=true → должен вызваться discovery-interview; false → должен сработать другой инструмент (expected_skill). Особое внимание — near-miss к process-analysis (C10).",
|
||||
"evals": [
|
||||
{ "id": 1, "should_trigger": true, "expected_skill": "discovery-interview/FEATURE", "prompt": "менеджеры жалуются что не видят, какие сделки сегодня надо обзвонить — каждое утро роются в фильтрах вручную" },
|
||||
{ "id": 2, "should_trigger": false, "expected_skill": "process-analysis", "prompt": "у меня ощущение что лиды из B2 проседают по конверсии, но не пойму почему — хочу разобраться" },
|
||||
{ "id": 3, "should_trigger": true, "expected_skill": "discovery-interview/FEATURE", "prompt": "хочу чтобы поставщики сами видели свой баланс, а то постоянно пишут в поддержку спрашивают" },
|
||||
{ "id": 4, "should_trigger": true, "expected_skill": "discovery-interview/FEATURE", "prompt": "проведи discovery interview по идее напоминаний — я пока сам не уверен что именно нужно" },
|
||||
{ "id": 5, "should_trigger": true, "expected_skill": "discovery-interview/FEATURE", "prompt": "не нравится как сейчас сделана выгрузка отчётов, неудобно, давай покопаем что не так" },
|
||||
{ "id": 6, "should_trigger": true, "expected_skill": "discovery-interview/FEATURE", "prompt": "клиенты часто отваливаются на этапе оплаты, надо понять что там за проблема" },
|
||||
{ "id": 7, "should_trigger": true, "expected_skill": "discovery-interview/SYSTEM", "prompt": "сориентируй меня — где мы сейчас по проекту, что закрыто что нет" },
|
||||
{ "id": 8, "should_trigger": true, "expected_skill": "discovery-interview/SYSTEM", "prompt": "что у нас вообще в тулчейне по безопасности, я запутался" },
|
||||
{ "id": 9, "should_trigger": true, "expected_skill": "discovery-interview/SYSTEM", "prompt": "вернулся после недели отсутствия, сделай catch-up что произошло по проекту" },
|
||||
{ "id": 10, "should_trigger": true, "expected_skill": "discovery-interview/SYSTEM", "prompt": "что там на карте в разделе биллинга, какие узлы" },
|
||||
{ "id": 11, "should_trigger": false, "expected_skill": "process-analysis", "prompt": "как устроен процесс обработки сделки от создания до закрытия — пройди по коду" },
|
||||
{ "id": 12, "should_trigger": false, "expected_skill": "process-analysis", "prompt": "где узкое место в воронке лидов, какой шаг тормозит" },
|
||||
{ "id": 13, "should_trigger": false, "expected_skill": "process-analysis", "prompt": "сделай process discovery по джобам импорта лидов" },
|
||||
{ "id": 14, "should_trigger": false, "expected_skill": "process-analysis", "prompt": "посчитай метрики процесса: cycle time по статусам сделок" },
|
||||
{ "id": 15, "should_trigger": false, "expected_skill": "directive (no skill)", "prompt": "интегрируй openapi-mcp-server в .mcp.json" },
|
||||
{ "id": 16, "should_trigger": false, "expected_skill": "directive (no skill)", "prompt": "закрой находку аудита G7 по AdminBillingController" },
|
||||
{ "id": 17, "should_trigger": false, "expected_skill": "systematic-debugging", "prompt": "поправь падающий тест RlsSmokeTest, он валится на teardown" },
|
||||
{ "id": 18, "should_trigger": false, "expected_skill": "directive (no skill)", "prompt": "добавь endpoint POST /api/deals/{id}/archive" },
|
||||
{ "id": 19, "should_trigger": false, "expected_skill": "write-spec / brainstorming", "prompt": "напиши спеку для фичи мультивалютного биллинга" },
|
||||
{ "id": 20, "should_trigger": false, "expected_skill": "audit-portal", "prompt": "проведи полный аудит портала перед релизом" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# Банк вопросов JTBD — режим FEATURE
|
||||
|
||||
Вопросы для discovery-интервью. Задавать **по одному**, адаптируя формулировку под
|
||||
контекст. Все вопросы — про прошлое поведение, без подсказанного ответа.
|
||||
|
||||
## 1. Вскрыть проблему
|
||||
|
||||
- Расскажи, что произошло в последний раз, когда [ситуация]?
|
||||
- Что именно тебя в этом раздражало или замедляло?
|
||||
- Как часто это случается?
|
||||
|
||||
## 2. Текущий обходной путь
|
||||
|
||||
- Как ты решаешь это сейчас?
|
||||
- Что делаешь, когда [проблема] происходит?
|
||||
- Кто ещё это делает и как?
|
||||
|
||||
## 3. Цена боли
|
||||
|
||||
- Сколько времени это съедает за неделю?
|
||||
- Что случается, если не сделать это вовремя?
|
||||
- Были случаи, когда из-за этого что-то сорвалось?
|
||||
|
||||
## 4. JTBD — какую работу «нанимают» решение сделать
|
||||
|
||||
- Если бы это работало идеально — что бы ты перестал делать руками?
|
||||
- Какого результата ты на самом деле добиваешься?
|
||||
|
||||
## 5. Сигнал успеха
|
||||
|
||||
- Как ты поймёшь, что проблема закрыта?
|
||||
- Что должно стать видимо иначе?
|
||||
|
||||
## 6. Ограничения
|
||||
|
||||
- Что нельзя ломать или менять?
|
||||
- Есть ли срок?
|
||||
|
||||
## Антипаттерны
|
||||
|
||||
- **Наводящий вопрос** («тебе мешает отсутствие X?») — подсказывает ответ; заказчик
|
||||
согласится из вежливости.
|
||||
- **Гипотетика** («как бы ты хотел?») — люди плохо предсказывают своё поведение.
|
||||
- **Список вопросов разом** — это анкета, не интервью; теряется ветвление по ответам.
|
||||
- **Принять первый ответ за корень** — копай «5 почему» до настоящей причины.
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: process-analysis
|
||||
description: Анализ и оптимизация существующего бизнес-процесса — process discovery (реконструкция as-is процесса из кода Laravel и audit-логов), поиск узких мест, трассировка требование→процесс, метрики и KPI процесса. Триггеры — «проанализируй процесс», «где узкое место», «process discovery», «как устроен процесс X», «метрики процесса», «оптимизируй процесс». Раздел C10 карты «Бизнес-процессы (общее)».
|
||||
---
|
||||
|
||||
# Process Analysis
|
||||
|
||||
Разбирает **существующий** бизнес-процесс: восстанавливает фактическую модель,
|
||||
находит узкие места, считает метрики. Парный скил к `process-modeling` — тот
|
||||
проектирует to-be, этот вскрывает as-is.
|
||||
|
||||
## Четыре режима
|
||||
|
||||
### 1. Process discovery — реконструкция as-is
|
||||
|
||||
Восстановить фактический процесс из артефактов кода (карта источников —
|
||||
`references/discovery.md`): маршруты + контроллеры (точки входа), джобы/события
|
||||
(асинхронные шаги), enum статусов + переходы (state-машина), audit-таблицы
|
||||
(фактические следы), cron/scheduler (периодические шаги). Итог — модель,
|
||||
которую можно передать `process-modeling` для отрисовки.
|
||||
|
||||
### 2. Bottleneck — поиск узких мест
|
||||
|
||||
Паттерны: ручной шаг между авто-шагами; шаг с ожиданием внешней системы; точка
|
||||
сериализации (advisory-lock, `lockForUpdate`); N+1 внутри шага; ретраи/таймауты;
|
||||
шаг с наибольшей долей исключений.
|
||||
Граница: это **процессные** узкие места. Runtime/код-производительность —
|
||||
`perf-analyzer` / скил `analysis:bottleneck-detect` (PA1).
|
||||
|
||||
### 3. Трассировка требование→процесс
|
||||
|
||||
Связать пункт ТЗ / `Открытые_вопросы` → шаги процесса → код (file:line) →
|
||||
тесты. Выявить шаги без требования (скрытая логика) и требования без
|
||||
реализации.
|
||||
|
||||
### 4. Метрики процесса
|
||||
|
||||
Определить KPI: throughput, cycle time, конверсия между статусами, доля
|
||||
исключений, объём ручного труда. Числа берутся из БД через `Boost`, не
|
||||
выдумываются.
|
||||
Граница: продуктовые метрики — плагин `product-management` (`/metrics-review`).
|
||||
|
||||
## Рабочий процесс
|
||||
|
||||
1. Определить режим (1-4) по запросу.
|
||||
2. Собрать факты из кода / БД / логов — никаких допущений без пинов (file:line).
|
||||
3. Выдать находки: модель / список узких мест / матрицу трассировки / таблицу
|
||||
метрик.
|
||||
4. Рекомендации направить в `process-modeling` (to-be) или в задачи. Этот скил
|
||||
код не правит.
|
||||
|
||||
## Границы
|
||||
|
||||
- **Проектирование to-be модели** — скил `process-modeling`.
|
||||
- **Runtime / код-производительность** — `perf-analyzer`,
|
||||
`analysis:bottleneck-detect` (PA1).
|
||||
- **Продуктовые метрики** — плагин `product-management`.
|
||||
- **Документ / change-request процесса** — плагин `operations`.
|
||||
- **Интервью заказчика про будущую фичу / ориентация по проекту** — скил
|
||||
`discovery-interview`. Тот вскрывает проблему до решения через интервью человека
|
||||
(режим FEATURE) и синтезирует мета-слой проекта (режим SYSTEM); этот скил — про
|
||||
вскрытие as-is процесса из app-кода. «process discovery», «как устроен процесс X»,
|
||||
«где узкое место» — сюда; «проведи discovery interview», «сориентируй по проекту» —
|
||||
в `discovery-interview`.
|
||||
- **Генерик-методология оптимизации процесса** — скил `process-optimization`
|
||||
плагина `operations`. Этот скил — про code-grounded discovery конкретного
|
||||
процесса Лидерры (вскрытие as-is), не про общую методологию и не про
|
||||
проектирование to-be.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Process discovery — карта источников as-is процесса в Лидерре
|
||||
|
||||
Где в коде Лидерры лежат факты о фактическом бизнес-процессе.
|
||||
|
||||
## Источники
|
||||
|
||||
| Артефакт процесса | Где искать |
|
||||
|---|---|
|
||||
| Точки входа процесса | `app/routes/*.php` + `app/app/Http/Controllers/**` |
|
||||
| Синхронные шаги | методы контроллеров + `app/app/Services/**` |
|
||||
| Асинхронные шаги | `app/app/Jobs/**`, `app/app/Events/**` + listeners |
|
||||
| State-машина | enum/константы статусов + `db/schema.sql` (воронка — 14 статусов) |
|
||||
| Фактические следы выполнения | `audit_*` таблицы, `audit_chain_hash` (событийный лог) |
|
||||
| Периодические шаги | `app/app/Console/**` + scheduler (`partitions:create-months` и пр.) |
|
||||
| Бизнес-правила в шагах | `calc_lead_score` (SQL), `PricingTierResolver`, `LedgerService` |
|
||||
|
||||
## Метод
|
||||
|
||||
1. От **точки входа** (route → controller) пройти по вызовам до терминального
|
||||
состояния.
|
||||
2. Каждый `dispatch()` / событие — асинхронная ветка; проследить listener/job.
|
||||
3. Переход статуса = ребро state-машины; собрать все переходы в автомат.
|
||||
4. Свериться с **audit-логом**: фактический порядок событий в `audit_*` может
|
||||
расходиться с «проектным» — расхождение само по себе находка.
|
||||
5. Зафиксировать каждый шаг пином `file:line`; без пина — это допущение, не факт.
|
||||
|
||||
## Антипаттерны при discovery
|
||||
|
||||
- Принять «happy path» за весь процесс — исключения (catch, failed jobs,
|
||||
таймауты) тоже шаги.
|
||||
- Пропустить cron-шаги — они не видны из route-графа.
|
||||
- Доверять имени метода вместо его тела.
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: process-modeling
|
||||
description: Моделирование бизнес-процесса — BPMN 2.0 (пулы, дорожки, задачи, гейтвеи, события), карты процессов, customer-journey / value-stream, RACI-матрицы, state-машины. Триггеры — «смоделируй процесс», «нарисуй BPMN», «карта процесса», «swimlane / дорожки», «customer journey», «RACI», проектирование state-машины (воронка сделок, цепочка джобов). Раздел C10 карты «Бизнес-процессы (общее)».
|
||||
---
|
||||
|
||||
# Process Modeling
|
||||
|
||||
Превращает словесное описание бизнес-процесса в формальную модель. Скил даёт
|
||||
**нотацию и методологию** — рендер диаграмм делегируется скилу `mermaid`
|
||||
(process-modeling не рендерит сам — конфликт-граница OPS1/BPMN1: mermaid
|
||||
остаётся рендер-SoT).
|
||||
|
||||
## Когда какой артефакт
|
||||
|
||||
| Нужно | Артефакт |
|
||||
|---|---|
|
||||
| Кто-что-в-каком-порядке делает, с ветвлениями | BPMN 2.0 / swimlane |
|
||||
| Сквозной поток end-to-end крупными блоками | Карта процесса (flowchart) |
|
||||
| Опыт клиента/лида по этапам + точки боли | Customer-journey map |
|
||||
| Поток создания ценности + потери и ожидания | Value-stream map |
|
||||
| Распределение ответственности по шагам | RACI-матрица |
|
||||
| Конечный автомат (статусы + переходы) | State-диаграмма |
|
||||
|
||||
## Рабочий процесс
|
||||
|
||||
1. **Собрать процесс** — уточнить: триггер (что запускает), участники (роли),
|
||||
шаги по порядку, ветвления и условия, итог, исключения. Неясное — один
|
||||
вопрос за раз.
|
||||
2. **Выбрать артефакт** по таблице выше.
|
||||
3. **Построить модель** в нотации (BPMN — см. `references/bpmn.md`).
|
||||
4. **Отрендерить** — передать исходник скилу `mermaid`.
|
||||
5. **Свериться** — модель не должна противоречить ТЗ / `db/schema.sql` /
|
||||
`Открытые_вопросы`. Процесс вне ТЗ И не в реестре открытых вопросов —
|
||||
hard-стоп (Pravila §7): не моделировать молча, поднять вопрос.
|
||||
|
||||
## BPMN 2.0 — ядро
|
||||
|
||||
Полная нотация и маппинг на mermaid — `references/bpmn.md`. Кратко:
|
||||
|
||||
- **Pool** — организация/система; **Lane** — роль внутри pool.
|
||||
- **Task** — атомарное действие; **Sub-process** — свёрнутый под-поток.
|
||||
- **Gateway** — ветвление: exclusive (XOR — один путь), parallel (AND — все
|
||||
пути), inclusive (OR — один и более).
|
||||
- **Event** — start / intermediate / end; типы: timer, message, error.
|
||||
- **Sequence flow** — порядок внутри pool; **Message flow** — между pool'ами.
|
||||
|
||||
## Границы
|
||||
|
||||
- **Рендер диаграмм** — скил `mermaid` (C10 OPS1/BPMN1). Этот скил исходник не
|
||||
рисует — отдаёт его mermaid.
|
||||
- **DDD-границы доменных процессов** — скил `architecture-patterns` (bounded
|
||||
context = граница бизнес-процесса).
|
||||
- **Документ процесса, change-request, оптимизация** — плагин `operations`
|
||||
(скилы `process-doc`, `change-request`, `process-optimization`).
|
||||
- **Анализ as-is процесса** (discovery, узкие места) — скил `process-analysis`.
|
||||
- Этот скил — про проектирование **to-be модели**, не про вскрытие as-is.
|
||||
@@ -0,0 +1,56 @@
|
||||
# BPMN 2.0 — справочник нотации и рендер в mermaid
|
||||
|
||||
mermaid не имеет нативного BPMN-рендера. BPMN-модель выражается через mermaid
|
||||
`flowchart` (swimlane через `subgraph` = дорожки) или `stateDiagram-v2`.
|
||||
|
||||
## Элементы BPMN → mermaid
|
||||
|
||||
| BPMN | Смысл | mermaid-выражение |
|
||||
|---|---|---|
|
||||
| Pool / Lane | организация / роль | `subgraph Роль ... end` |
|
||||
| Task | действие | прямоугольник `id[Текст]` |
|
||||
| Sub-process | свёрнутый поток | `id[[Текст]]` |
|
||||
| Start event | старт | `id((Старт))` |
|
||||
| End event | конец | `id((Конец))` |
|
||||
| Exclusive gateway (XOR) | один путь | ромб `id{Условие?}` + подписи на рёбрах |
|
||||
| Parallel gateway (AND) | все пути | ромб `id{И}` с несколькими исходящими |
|
||||
| Sequence flow | порядок | `-->` |
|
||||
| Message flow | между pool | `-.->` |
|
||||
|
||||
## Шаблон swimlane
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Менеджер
|
||||
A((Старт)) --> B[Принять лид]
|
||||
B --> C{Лид валиден?}
|
||||
end
|
||||
subgraph Система
|
||||
C -->|да| D[Создать сделку]
|
||||
C -->|нет| E((Отклонён))
|
||||
D --> F((Сделка создана))
|
||||
end
|
||||
```
|
||||
|
||||
## State-машина
|
||||
|
||||
Для конечных автоматов (воронка сделок — 14 статусов из `db/schema.sql`)
|
||||
использовать `stateDiagram-v2`:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> new
|
||||
new --> in_progress
|
||||
in_progress --> won
|
||||
in_progress --> lost
|
||||
won --> [*]
|
||||
lost --> [*]
|
||||
```
|
||||
|
||||
Статус-слаги — из `db/schema.sql` (источник истины воронки), не выдумывать.
|
||||
|
||||
## Правила
|
||||
|
||||
- Один gateway — один вопрос; каждое исходящее ребро подписано условием.
|
||||
- Каждый путь оканчивается end-событием (нет «висящих» задач).
|
||||
- Исключения (timer/error) моделировать явно, не прятать в «happy path».
|
||||
@@ -4,3 +4,4 @@ bin/
|
||||
CLAUDE.md
|
||||
.claude/skills/mermaid/
|
||||
.claude/skills/ccpm/
|
||||
.claude/skills/data-scientist/
|
||||
|
||||
@@ -43,6 +43,20 @@
|
||||
"command": "npx",
|
||||
"args": ["-y", "ruflo@latest", "mcp", "start"],
|
||||
"comment": "Off-phase orchestration MCP — exposes ~210 ruflo tools (Core/Intelligence/Agents/Memory/DevTools). Package: ruflo v3.7.0-alpha.38+ MIT (npm `ruflo`, repo ruvnet/claude-flow legacy after rename Jan-2026; plugin namespace @claude-flow/*). Plugin discovery via IPFS (CID QmeXmAdbWVvT84GfDXPD2Vg1HWhiTW2VdZfRLhkS96KkX2) — Pinata+Cloudflare gateways flaky 2026-05-15, only ipfs.io reliable. stdio mode (no port-conflict). Big-bang integration per spec/plan 2026-05-15-ruflo-integration-design.md (commit a68a0a0+). Pending формализация в Tooling §4.10 — Phase 3 Task 3.4."
|
||||
},
|
||||
"universal-icons": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-universal-icons"],
|
||||
"comment": "Off-phase A4 design-tooling #45 — Universal Icons MCP (npm mcp-universal-icons, awssat, MIT). Поиск/вставка SVG-иконок из 10 коллекций, включая Lucide (проектный icon-set, CTO-19). Tools: search_icons / get_icon / health_check. SVG framework-neutral по умолчанию — НЕ запрашивать jsx/Tailwind-формат (PSR_v1 R6.0). Формализация — Tooling §4.20. ADR-006 граница UI2: иконки UI; бренд-логотипы — за 21st logo_search. План docs/superpowers/plans/2026-05-17-a4-design-tooling-integration.md."
|
||||
},
|
||||
"openapi": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@ivotoby/openapi-mcp-server"],
|
||||
"env": {
|
||||
"API_BASE_URL": "http://localhost",
|
||||
"OPENAPI_SPEC_PATH": "./docs/api/openapi.yaml"
|
||||
},
|
||||
"comment": "A3 integration-tooling #47 — OpenAPI MCP (ivo-toby/mcp-openapi-server, @ivotoby/openapi-mcp-server v1.14.0, MIT). Exposes Лидерра REST API endpoints (docs/api/openapi.yaml) as MCP tools. Config via env-vars API_BASE_URL + OPENAPI_SPEC_PATH (stdio transport default). READ scope: API discovery/introspection for Claude Code. Формализован в Tooling §4.22, PSR_v1 R10.1 блок 3, Pravila §13.2."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.deptrac.cache
|
||||
/.codex
|
||||
/.cursor/
|
||||
/.idea
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Casts;
|
||||
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Eloquent cast for PostgreSQL native INT[] columns.
|
||||
*
|
||||
* Laravel stock 'array' cast uses json_encode/json_decode and sends `[1,2,3]`
|
||||
* (JSON), which Postgres rejects on INT[] columns (expects `{1,2,3}` array
|
||||
* literal). This cast:
|
||||
*
|
||||
* - get(): parses Postgres array literal `{1,2,3}` (or empty `{}`) into PHP
|
||||
* int array.
|
||||
* - set(): serializes PHP array `[1,2,3]` into Postgres literal `{1,2,3}`.
|
||||
*
|
||||
* Used for projects.regions INT[] (Plan 6).
|
||||
*
|
||||
* @implements CastsAttributes<list<int>, list<int>|null>
|
||||
*/
|
||||
class PostgresIntArray implements CastsAttributes
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return list<int>
|
||||
*/
|
||||
public function get(Model $model, string $key, mixed $value, array $attributes): array
|
||||
{
|
||||
if ($value === null || $value === '' || $value === '{}') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// PG returns literal like "{1,2,3}".
|
||||
if (is_string($value)) {
|
||||
$trimmed = trim($value, '{}');
|
||||
|
||||
if ($trimmed === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map('intval', explode(',', $trimmed));
|
||||
}
|
||||
|
||||
// Defensive: if driver already gave array.
|
||||
if (is_array($value)) {
|
||||
return array_values(array_map('intval', $value));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Defensive: interface phpdoc says list<int>|null, but $value is mixed at PHP level;
|
||||
// protect against runtime misuse (e.g., string passed mistakenly).
|
||||
// @phpstan-ignore function.alreadyNarrowedType
|
||||
if (! is_array($value)) {
|
||||
throw new \InvalidArgumentException(
|
||||
"PostgresIntArray cast expects array for key '{$key}', got ".gettype($value)
|
||||
);
|
||||
}
|
||||
|
||||
if ($value === []) {
|
||||
return '{}';
|
||||
}
|
||||
|
||||
$ints = array_map('intval', $value);
|
||||
|
||||
return '{'.implode(',', $ints).'}';
|
||||
}
|
||||
}
|
||||
@@ -63,10 +63,10 @@ class DashboardController extends Controller
|
||||
$curLeads = (clone $base())->whereBetween('received_at', [$windowStart, $now])->count();
|
||||
$prevLeads = (clone $base())->whereBetween('received_at', [$prevStart, $windowStart])->count();
|
||||
|
||||
// --- conversion: % статуса 'paid' в окне ---
|
||||
$curPaid = (clone $base())->where('status', 'paid')
|
||||
// --- conversion: % статуса 'won' в окне ---
|
||||
$curPaid = (clone $base())->where('status', 'won')
|
||||
->whereBetween('received_at', [$windowStart, $now])->count();
|
||||
$prevPaid = (clone $base())->where('status', 'paid')
|
||||
$prevPaid = (clone $base())->where('status', 'won')
|
||||
->whereBetween('received_at', [$prevStart, $windowStart])->count();
|
||||
$curConv = $curLeads > 0 ? round($curPaid / $curLeads * 100, 1) : 0.0;
|
||||
$prevConv = $prevLeads > 0 ? round($prevPaid / $prevLeads * 100, 1) : 0.0;
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Models\User;
|
||||
use App\Services\SupplierResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
@@ -55,6 +56,11 @@ class DealController extends Controller
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$request->validate([
|
||||
'received_from' => 'nullable|date',
|
||||
'received_to' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$statuses = (array) $request->query('status_in', []);
|
||||
$projectId = $request->query('project_id') !== null ? (int) $request->query('project_id') : null;
|
||||
$managerId = $request->query('manager_id') !== null ? (int) $request->query('manager_id') : null;
|
||||
@@ -64,6 +70,8 @@ class DealController extends Controller
|
||||
$onlyDeleted = $request->boolean('only_deleted');
|
||||
$countOnly = $request->boolean('count_only');
|
||||
$cursorRaw = (string) $request->query('cursor', '');
|
||||
$receivedFrom = trim((string) $request->query('received_from', ''));
|
||||
$receivedTo = trim((string) $request->query('received_to', ''));
|
||||
|
||||
// Sprint 4 Phase A (audit O-perf-04): keyset pagination через cursor.
|
||||
// При передаче cursor — keyset через PG row constructor (received_at, id) < (?, ?),
|
||||
@@ -81,7 +89,7 @@ class DealController extends Controller
|
||||
$cursor = ['r' => (string) $parsed['r'], 'i' => (int) $parsed['i']];
|
||||
}
|
||||
|
||||
[$deals, $total, $nextCursor] = DB::transaction(function () use ($tenantId, $statuses, $projectId, $managerId, $search, $limit, $offset, $onlyDeleted, $cursor, $countOnly) {
|
||||
[$deals, $total, $nextCursor] = DB::transaction(function () use ($tenantId, $statuses, $projectId, $managerId, $search, $limit, $offset, $onlyDeleted, $cursor, $countOnly, $receivedFrom, $receivedTo) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
// Defense-in-depth: явный where(tenant_id) поверх RLS — на тестах
|
||||
@@ -92,8 +100,16 @@ class DealController extends Controller
|
||||
// withTrashed() обходит global scope SoftDeletes; явный
|
||||
// whereNotNull('deleted_at') фильтрует только удалённые.
|
||||
$query = Deal::query()
|
||||
->select('deals.*')
|
||||
->addSelect(['next_reminder_at' => DB::table('reminders')
|
||||
->select('remind_at')
|
||||
->whereColumn('reminders.deal_id', 'deals.id')
|
||||
->whereNull('reminders.completed_at')
|
||||
->orderBy('remind_at')
|
||||
->limit(1),
|
||||
])
|
||||
->where('tenant_id', $tenantId)
|
||||
->with(['project:id,name', 'manager:id,email,first_name,last_name']);
|
||||
->with(['project:id,name,signal_type', 'manager:id,email,first_name,last_name']);
|
||||
|
||||
if ($onlyDeleted) {
|
||||
$query->withTrashed()->whereNotNull('deleted_at');
|
||||
@@ -115,6 +131,13 @@ class DealController extends Controller
|
||||
->orWhere('contact_name', 'ilike', $like);
|
||||
});
|
||||
}
|
||||
if ($receivedFrom !== '') {
|
||||
$query->where('received_at', '>=', Carbon::parse($receivedFrom)->startOfDay());
|
||||
}
|
||||
if ($receivedTo !== '') {
|
||||
// received_to включительно — до конца дня (+1 день, строгое <).
|
||||
$query->where('received_at', '<', Carbon::parse($receivedTo)->addDay()->startOfDay());
|
||||
}
|
||||
|
||||
// Audit B2: count_only — отдаём только COUNT(*), пропуская SELECT строк
|
||||
// и cursor/offset-логику (лёгкий запрос для бейджа в сайдбаре).
|
||||
@@ -187,6 +210,12 @@ class DealController extends Controller
|
||||
? ManagerController::formatInitials($d->manager->first_name, $d->manager->last_name, $d->manager->email)
|
||||
: null,
|
||||
'received_at' => $d->received_at?->toIso8601String(),
|
||||
'comment' => $d->comment,
|
||||
'city' => $d->city,
|
||||
'project_signal_type' => $d->project?->signal_type,
|
||||
'next_reminder_at' => $d->next_reminder_at
|
||||
? Carbon::parse($d->next_reminder_at)->toIso8601String()
|
||||
: null,
|
||||
]),
|
||||
'limit' => $limit,
|
||||
'next_cursor' => $nextCursor,
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Deal;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use OpenSpout\Common\Entity\Row;
|
||||
use OpenSpout\Common\Entity\Style\Style;
|
||||
@@ -16,44 +17,45 @@ use OpenSpout\Writer\XLSX\Writer as XlsxWriter;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* Export сделок в CSV / XLSX через OpenSpout streaming.
|
||||
* Экспорт сделок в CSV / XLSX через OpenSpout streaming.
|
||||
*
|
||||
* Извлечено из DealController (Sprint 3 Phase A, audit O-refactor-01).
|
||||
* Редизайн «Сделки» (2026-05-17, Task A5): экспорт по ДИАПАЗОНУ ДАТ поставки
|
||||
* (received_at), не по списку id. Окно задаётся received_from/received_to;
|
||||
* оба опциональны (пусто = весь период). Колонки соответствуют таблице
|
||||
* страницы (без чекбокса и без «Напоминание» — экспорт = дамп лидов).
|
||||
*
|
||||
* RLS-обёртка SET LOCAL внутри транзакции (PgBouncer-safe).
|
||||
*
|
||||
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
|
||||
*
|
||||
* O-perf-05: streaming устраняет memory pressure. PhpSpreadsheet строил
|
||||
* полный объект .xlsx в памяти (для 10K сделок ≈ 100+ MB). OpenSpout пишет
|
||||
* O-perf-05: streaming устраняет memory pressure. OpenSpout пишет
|
||||
* в php://output постранично через Writer + Row::fromValues и chunkById(500)
|
||||
* по сделкам — пик памяти O(1) от размера экспорта.
|
||||
*
|
||||
* API контракт сохранён:
|
||||
* POST /api/deals/export {ids[], format?: csv|xlsx}
|
||||
* Headers Content-Type / Content-Disposition без изменений.
|
||||
* CSV: UTF-8 + BOM + ;-разделитель (Excel-friendly RU-локаль).
|
||||
* XLSX: bold-header + auto-size columns.
|
||||
*
|
||||
* RLS-обёртка SET LOCAL внутри транзакции (PgBouncer-safe). Чужие id
|
||||
* отфильтрует where(tenant_id) defense-in-depth.
|
||||
*/
|
||||
class DealExportController extends Controller
|
||||
{
|
||||
/** Заголовки таблицы — общие для CSV и XLSX. */
|
||||
private const HEADERS = ['ID', 'Имя', 'Телефон', 'Статус', 'Проект ID', 'Менеджер ID', 'Получено'];
|
||||
/** Заголовки — общие для CSV и XLSX. */
|
||||
private const HEADERS = ['Телефон', 'Источник', 'Город', 'Статус', 'Комментарий', 'Поставлен'];
|
||||
|
||||
/** signal_type → русская метка для колонки «Источник». */
|
||||
private const SIGNAL_LABELS = ['call' => 'Звонки', 'site' => 'Сайт', 'sms' => 'СМС'];
|
||||
|
||||
public function export(Request $request): StreamedResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'ids' => 'required|array|min:1|max:10000',
|
||||
'ids.*' => 'integer|min:1',
|
||||
'received_from' => 'nullable|date',
|
||||
'received_to' => 'nullable|date',
|
||||
'format' => 'nullable|string|in:csv,xlsx',
|
||||
]);
|
||||
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$format = $validated['format'] ?? 'csv';
|
||||
$filename = 'deals_export_'.now()->format('Y-m-d').'.'.$format;
|
||||
$from = isset($validated['received_from']) && $validated['received_from'] !== ''
|
||||
? Carbon::parse($validated['received_from'])->startOfDay() : null;
|
||||
$to = isset($validated['received_to']) && $validated['received_to'] !== ''
|
||||
? Carbon::parse($validated['received_to'])->addDay()->startOfDay() : null;
|
||||
|
||||
$filename = 'deals_export_'.now()->format('Y-m-d').'.'.$format;
|
||||
$headers = $format === 'xlsx'
|
||||
? [
|
||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
@@ -64,14 +66,16 @@ class DealExportController extends Controller
|
||||
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
|
||||
];
|
||||
|
||||
return new StreamedResponse(function () use ($validated, $tenantId, $format) {
|
||||
return new StreamedResponse(function () use ($tenantId, $format, $from, $to) {
|
||||
// RLS-контекст должен быть установлен внутри транзакции на момент
|
||||
// фактического SELECT. StreamedResponse callback вызывается уже
|
||||
// после Laravel-response pipeline'а, поэтому открываем транзакцию
|
||||
// прямо здесь.
|
||||
DB::transaction(function () use ($validated, $tenantId, $format) {
|
||||
DB::transaction(function () use ($tenantId, $format, $from, $to) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$statusNames = DB::table('lead_statuses')->pluck('name_ru', 'slug');
|
||||
|
||||
$writer = $this->openWriter($format);
|
||||
$writer->openToFile('php://output');
|
||||
|
||||
@@ -81,32 +85,41 @@ class DealExportController extends Controller
|
||||
if ($format === 'xlsx') {
|
||||
/** @var XlsxWriter $writer */
|
||||
$writer->getCurrentSheet()->setName('Сделки');
|
||||
$headerStyle = (new Style)->withFontBold(true);
|
||||
$writer->addRow(Row::fromValuesWithStyle(self::HEADERS, $headerStyle));
|
||||
$writer->addRow(Row::fromValuesWithStyle(self::HEADERS, (new Style)->withFontBold(true)));
|
||||
} else {
|
||||
$writer->addRow(Row::fromValues(self::HEADERS));
|
||||
}
|
||||
|
||||
// chunkById(500) — keyset-friendly; в нашем DealsView это
|
||||
// редкий тяжёлый action, экспортировать могут до 10K id.
|
||||
Deal::query()
|
||||
$query = Deal::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('id', $validated['ids'])
|
||||
->orderBy('id')
|
||||
->chunkById(500, function ($deals) use ($writer) {
|
||||
foreach ($deals as $deal) {
|
||||
/** @var Deal $deal */
|
||||
$writer->addRow(Row::fromValues([
|
||||
$deal->id,
|
||||
(string) ($deal->contact_name ?? ''),
|
||||
(string) $deal->phone,
|
||||
(string) $deal->status,
|
||||
$deal->project_id,
|
||||
$deal->manager_id ?? '',
|
||||
$deal->received_at->toDateTimeString(),
|
||||
]));
|
||||
}
|
||||
});
|
||||
->with('project:id,name,signal_type')
|
||||
->orderByDesc('received_at');
|
||||
|
||||
if ($from !== null) {
|
||||
$query->where('received_at', '>=', $from);
|
||||
}
|
||||
if ($to !== null) {
|
||||
$query->where('received_at', '<', $to);
|
||||
}
|
||||
|
||||
// chunkById(500) — keyset-friendly; deals.id — BIGSERIAL (unique),
|
||||
// корректно для чанкинга даже при партиционированной PK (id, received_at).
|
||||
$query->chunkById(500, function ($deals) use ($writer, $statusNames) {
|
||||
foreach ($deals as $deal) {
|
||||
/** @var Deal $deal */
|
||||
$signal = $deal->project?->signal_type;
|
||||
$source = trim(($deal->project?->name ?? '—').' · '
|
||||
.(self::SIGNAL_LABELS[$signal] ?? '—'));
|
||||
$writer->addRow(Row::fromValues([
|
||||
(string) $deal->phone,
|
||||
$source,
|
||||
(string) ($deal->city ?? ''),
|
||||
(string) ($statusNames[$deal->status] ?? $deal->status),
|
||||
(string) ($deal->comment ?? ''),
|
||||
$deal->received_at?->toDateTimeString() ?? '',
|
||||
]));
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
$writer->close();
|
||||
});
|
||||
@@ -120,12 +133,10 @@ class DealExportController extends Controller
|
||||
}
|
||||
|
||||
// CSV: ;-разделитель + UTF-8 BOM (Excel-friendly RU-локаль).
|
||||
$options = new CsvOptions(
|
||||
return new CsvWriter(new CsvOptions(
|
||||
FIELD_DELIMITER: ';',
|
||||
FIELD_ENCLOSURE: '"',
|
||||
SHOULD_ADD_BOM: true,
|
||||
);
|
||||
|
||||
return new CsvWriter($options);
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,10 +32,17 @@ class BulkProjectActionRequest extends FormRequest
|
||||
'scope.filter.search' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
|
||||
if ($action === 'update_regions' || $action === 'update_days') {
|
||||
$maxMask = $action === 'update_regions' ? 255 : 127;
|
||||
$rules['add'] = ['nullable', 'integer', 'min:0', "max:{$maxMask}"];
|
||||
$rules['remove'] = ['nullable', 'integer', 'min:0', "max:{$maxMask}"];
|
||||
if ($action === 'update_regions') {
|
||||
// Plan 6.5: субъект-уровневые коды 1..89 (см. resources/js/constants/regions.ts).
|
||||
$rules['add_regions'] = ['nullable', 'array'];
|
||||
$rules['add_regions.*'] = ['integer', 'between:1,89'];
|
||||
$rules['remove_regions'] = ['nullable', 'array'];
|
||||
$rules['remove_regions.*'] = ['integer', 'between:1,89'];
|
||||
}
|
||||
|
||||
if ($action === 'update_days') {
|
||||
$rules['add'] = ['nullable', 'integer', 'min:0', 'max:127'];
|
||||
$rules['remove'] = ['nullable', 'integer', 'min:0', 'max:127'];
|
||||
}
|
||||
|
||||
if ($action === 'update_limit') {
|
||||
|
||||
@@ -22,8 +22,11 @@ class StoreProjectRequest extends FormRequest
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'signal_type' => ['required', Rule::in(['site', 'call', 'sms'])],
|
||||
'daily_limit_target' => ['required', 'integer', 'min:1', 'max:10000'],
|
||||
'region_mask' => ['required', 'integer', 'min:0'],
|
||||
'region_mode' => ['required', Rule::in(['include', 'exclude'])],
|
||||
// Plan 6: subject-level regions[] заменил region_mask/region_mode на API-уровне.
|
||||
// Empty array = "вся РФ" (паритет с legacy region_mask=255 + region_mode='include').
|
||||
// present = поле должно быть в payload (даже если []), enforces explicit choice.
|
||||
'regions' => ['present', 'array'],
|
||||
'regions.*' => ['integer', 'between:1,89'],
|
||||
'delivery_days_mask' => ['required', 'integer', 'min:1', 'max:127'],
|
||||
];
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateProjectRequest extends FormRequest
|
||||
{
|
||||
@@ -20,8 +19,10 @@ class UpdateProjectRequest extends FormRequest
|
||||
return [
|
||||
'name' => ['sometimes', 'string', 'max:255'],
|
||||
'daily_limit_target' => ['sometimes', 'integer', 'min:1', 'max:10000'],
|
||||
'region_mask' => ['sometimes', 'integer', 'min:0'],
|
||||
'region_mode' => ['sometimes', Rule::in(['include', 'exclude'])],
|
||||
// Plan 6: subject-level regions[] заменил region_mask/region_mode на API-уровне.
|
||||
// sometimes = поле omit-able (preserves prior DB value), массив + each 1..89.
|
||||
'regions' => ['sometimes', 'array'],
|
||||
'regions.*' => ['integer', 'between:1,89'],
|
||||
'delivery_days_mask' => ['sometimes', 'integer', 'min:1', 'max:127'],
|
||||
'sms_senders' => ['sometimes', 'array', 'min:1'],
|
||||
'sms_senders.*' => ['string', 'max:11'],
|
||||
|
||||
@@ -31,6 +31,7 @@ class ProjectResource extends JsonResource
|
||||
'archived_at' => $project->archived_at?->toIso8601String(),
|
||||
'region_mask' => $this->region_mask,
|
||||
'region_mode' => $this->region_mode,
|
||||
'regions' => $this->regions,
|
||||
'delivery_days_mask' => $this->delivery_days_mask,
|
||||
'sync_status' => $this->aggregateSyncStatus(),
|
||||
'last_synced_at' => $this->aggregateLastSyncedAt(),
|
||||
|
||||
@@ -207,7 +207,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
* Маппинг:
|
||||
* daily_limit ← daily_limit_target
|
||||
* workdays ← биты delivery_days_mask (bit 0=Пн, …, bit 6=Вс) → ISO 1..7
|
||||
* regions ← биты region_mask (bit 0=Центральный, …, bit 7=Дальневосточный) → 1..8
|
||||
* regions ← projects.regions INT[] (subject codes 1..89) direct copy
|
||||
*
|
||||
* @param EloquentCollection<int, Project> $projects
|
||||
* @return Collection<int, stdClass>
|
||||
@@ -219,12 +219,11 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
$obj->daily_limit = (int) $p->daily_limit_target;
|
||||
$obj->workdays = $this->bitmaskToList((int) $p->delivery_days_mask, 7);
|
||||
|
||||
// region_mask=255 (все 8 ФО, default) — catch-all семантика → пустой массив
|
||||
// у supplier ("без региональных ограничений"). Иначе — список выставленных битов.
|
||||
$regionMask = (int) $p->region_mask;
|
||||
$obj->regions = $regionMask === 255
|
||||
? []
|
||||
: $this->bitmaskToList($regionMask, 8);
|
||||
// Plan 6: projects.regions[] напрямую копируется в supplier_projects.current_regions.
|
||||
// Empty array = "вся РФ" (паритет с supplier API semantics).
|
||||
// Legacy region_mask/region_mode игнорируются — они dual-write для PhonePrefixService,
|
||||
// outbound к supplier использует только regions[]. Cleanup в Plan 6.5.
|
||||
$obj->regions = array_values((array) $p->regions);
|
||||
|
||||
return $obj;
|
||||
})->values();
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Casts\PostgresIntArray;
|
||||
use Carbon\CarbonInterface;
|
||||
use Database\Factories\ProjectFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -45,6 +46,9 @@ class Project extends Model
|
||||
'effective_limit_calculated_at',
|
||||
'region_mask',
|
||||
'region_mode',
|
||||
// Plan 6 (schema v8.20): Subject-level regions array (89 codes из resources/js/constants/regions.ts).
|
||||
// Источник истины с Plan 6+; region_mask/region_mode — DEPRECATED (Plan 6.5 cleanup).
|
||||
'regions',
|
||||
'delivery_days_mask',
|
||||
'assignment_strategy',
|
||||
'ttfr_target_minutes',
|
||||
@@ -69,6 +73,10 @@ class Project extends Model
|
||||
'daily_limit_target' => 'integer',
|
||||
'effective_daily_limit_today' => 'integer',
|
||||
'region_mask' => 'integer',
|
||||
// Plan 6: Subject-level regions array (89 codes). Используется кастомный
|
||||
// PostgresIntArray cast — Laravel stock 'array' посылает JSON `[1,2,3]`,
|
||||
// что Postgres отвергает на INT[] (ожидает literal `{1,2,3}`).
|
||||
'regions' => PostgresIntArray::class,
|
||||
'delivery_days_mask' => 'integer',
|
||||
'ttfr_target_minutes' => 'integer',
|
||||
'effective_limit_calculated_at' => 'datetime',
|
||||
|
||||
@@ -105,7 +105,7 @@ final class HistoricalImportService
|
||||
}
|
||||
|
||||
/**
|
||||
* Маппит статус: каноническая таблица §6.4 → tenant-override → fallback 'new'.
|
||||
* Маппит статус: StatusRuToSlugMapper → tenant-override → fallback 'new'.
|
||||
* Неизвестный статус инкрементит счётчик в $unknown по ссылке.
|
||||
*
|
||||
* @param array<string, string> $overrides
|
||||
|
||||
@@ -5,29 +5,36 @@ declare(strict_types=1);
|
||||
namespace App\Services\Import;
|
||||
|
||||
/**
|
||||
* Маппинг русских названий статусов воронки в slug (ТЗ §6.4).
|
||||
* Маппинг русских названий статусов (старые 14 названий поставщика + новые 5)
|
||||
* в slug 5-статусной воронки (редизайн 2026-05-17).
|
||||
*
|
||||
* Чистый сервис без зависимостей. Tenant-специфичные переопределения
|
||||
* неизвестных статусов накладываются вызывающим кодом (HistoricalImportService).
|
||||
*/
|
||||
class StatusRuToSlugMapper
|
||||
{
|
||||
/** @var array<string, string> Канонический маппинг ТЗ §6.4 (14 статусов воронки). */
|
||||
/** @var array<string, string> Русские названия → 5 slug'ов воронки (редизайн 2026-05-17). */
|
||||
private const STATUS_RU_TO_SLUG = [
|
||||
'Новые' => 'new',
|
||||
// Новые названия 5-статусной воронки.
|
||||
'Новая сделка' => 'new',
|
||||
'Просмотрено' => 'viewed',
|
||||
'Проработан' => 'worked',
|
||||
'База' => 'base',
|
||||
'Недозвон' => 'missed',
|
||||
'Переговоры' => 'negotiations',
|
||||
'Ожидаем оплаты' => 'waiting_payment',
|
||||
'Партнерка' => 'partnership',
|
||||
'Оплачено' => 'paid',
|
||||
'Закрыто и не реализовано' => 'closed',
|
||||
'Тест драйв' => 'test_drive',
|
||||
'Горячий' => 'hot',
|
||||
'На замену' => 'replacement',
|
||||
'Конечный недозвон' => 'final_missed',
|
||||
'В работе' => 'in_progress',
|
||||
'Сделка' => 'won',
|
||||
'Не реализовано' => 'lost',
|
||||
// Старые 14 названий поставщика → новые slug'и (исторический CSV-импорт).
|
||||
'Новые' => 'new',
|
||||
'Проработан' => 'in_progress',
|
||||
'База' => 'in_progress',
|
||||
'Недозвон' => 'in_progress',
|
||||
'Переговоры' => 'in_progress',
|
||||
'Ожидаем оплаты' => 'in_progress',
|
||||
'Партнерка' => 'in_progress',
|
||||
'Оплачено' => 'won',
|
||||
'Закрыто и не реализовано' => 'lost',
|
||||
'Тест драйв' => 'in_progress',
|
||||
'Горячий' => 'in_progress',
|
||||
'На замену' => 'in_progress',
|
||||
'Конечный недозвон' => 'in_progress',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -39,7 +46,8 @@ class StatusRuToSlugMapper
|
||||
}
|
||||
|
||||
/**
|
||||
* Полная каноническая таблица — для UI wizard'а (показать варианты).
|
||||
* Полная таблица соответствия: русское название → slug 5-статусной воронки
|
||||
* (18 ключей — старые и новые названия схлопываются в 5 slug'ов).
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
|
||||
@@ -114,15 +114,41 @@ class ProjectService
|
||||
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan 6.5: субъект-уровневый bulk-edit `regions` INT[].
|
||||
*
|
||||
* Для каждого проекта: regions := unique(regions ∪ add_regions) \ remove_regions,
|
||||
* отсортировано по возрастанию. `regions[]` — источник истины региональной
|
||||
* фильтрации с Plan 6 (outbound SyncSupplierProjectsJob читает именно его).
|
||||
* Legacy `region_mask` здесь не трогается — как и в одиночном PATCH
|
||||
* /api/projects/{id}; его удаление — Plan 6.5 cleanup.
|
||||
*
|
||||
* NB: проект с regions=[] («вся РФ») при add_regions сужается до выбранных
|
||||
* субъектов — это осознанное действие оператора bulk-диалога.
|
||||
*
|
||||
* Обновление идёт через model-инстанс (не query-builder mass update): каст
|
||||
* PostgresIntArray::set() сериализует PHP-массив в PG-литерал `{1,2,3}`, а
|
||||
* mass update каст не применяет. count ≤ BULK_MAX (500) — допустимо.
|
||||
*/
|
||||
private function bulkUpdateRegions($query, array $payload): array
|
||||
{
|
||||
$add = (int) ($payload['add'] ?? 0);
|
||||
$remove = (int) ($payload['remove'] ?? 0);
|
||||
$add = array_map('intval', $payload['add_regions'] ?? []);
|
||||
$remove = array_map('intval', $payload['remove_regions'] ?? []);
|
||||
|
||||
// region_mask = (region_mask | add) & ~remove, clamped to 8 bits (0–255)
|
||||
$updated = $query->update([
|
||||
'region_mask' => \DB::raw("(region_mask | {$add}) & ~{$remove} & 255"),
|
||||
]);
|
||||
if ($add === [] && $remove === []) {
|
||||
return ['updated' => 0, 'skipped' => [], 'warnings' => []];
|
||||
}
|
||||
|
||||
$projects = (clone $query)->get(['id', 'regions']);
|
||||
$updated = 0;
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$next = array_values(array_unique([...($project->regions ?? []), ...$add]));
|
||||
$next = array_values(array_diff($next, $remove));
|
||||
sort($next);
|
||||
$project->update(['regions' => $next]);
|
||||
$updated++;
|
||||
}
|
||||
|
||||
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
|
||||
}
|
||||
@@ -191,6 +217,11 @@ class ProjectService
|
||||
|
||||
$data['tenant_id'] = $tenant->id;
|
||||
$data['is_active'] = true;
|
||||
$data['regions'] = $data['regions'] ?? [];
|
||||
// Plan 6 dual-write: regions[] источник истины; region_mask/mode — legacy для
|
||||
// PhonePrefixService / LeadRouter, удаляются в Plan 6.5 после переключения читателей.
|
||||
$data['region_mask'] = 255;
|
||||
$data['region_mode'] = 'include';
|
||||
$project = Project::create($data);
|
||||
|
||||
SyncSupplierProjectJob::dispatch($project->id);
|
||||
|
||||
@@ -12,8 +12,8 @@ use Illuminate\Support\Facades\DB;
|
||||
* managers_summary — агрегат сделок по менеджерам за период (audit F1).
|
||||
*
|
||||
* Группировка по deals.manager_id; неназначенные (manager_id IS NULL) сводятся
|
||||
* в строку «Не назначен». «Оплачено» = status='paid' (won-статус воронки, как
|
||||
* в DashboardController). Конверсия = paid / total * 100, округление до 0.1.
|
||||
* в строку «Не назначен». «Оплачено» = status='won' (won-статус воронки, как
|
||||
* в DashboardController). Конверсия = won / total * 100, округление до 0.1.
|
||||
*
|
||||
* parameters: date_from, date_to (Y-m-d). Исключаются soft-deleted
|
||||
* (deleted_at IS NULL) и тестовые (is_test=false) сделки. RLS-обёртка
|
||||
@@ -48,7 +48,7 @@ class ManagersSummaryProvider implements ReportDataProvider
|
||||
"deals.manager_id,
|
||||
users.first_name, users.last_name, users.email,
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE deals.status = 'paid') AS paid"
|
||||
COUNT(*) FILTER (WHERE deals.status = 'won') AS paid"
|
||||
)
|
||||
->get();
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ use Illuminate\Support\Facades\DB;
|
||||
* sources_summary — агрегат сделок по источнику (utm_source) за период (audit F1).
|
||||
*
|
||||
* Группировка по deals.utm_source; сделки без метки (NULL/пусто) сводятся в
|
||||
* строку «Прямые / без метки». «Оплачено» = status='paid'. Конверсия =
|
||||
* paid / total * 100, округление до 0.1.
|
||||
* строку «Прямые / без метки». «Оплачено» = status='won'. Конверсия =
|
||||
* won / total * 100, округление до 0.1.
|
||||
*
|
||||
* parameters: date_from, date_to (Y-m-d). Исключаются soft-deleted и тестовые
|
||||
* сделки. RLS-обёртка SET LOCAL app.current_tenant_id — паттерн DealsExportProvider.
|
||||
@@ -45,7 +45,7 @@ class SourcesSummaryProvider implements ReportDataProvider
|
||||
->selectRaw(
|
||||
"utm_source,
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE status = 'paid') AS paid"
|
||||
COUNT(*) FILTER (WHERE status = 'won') AS paid"
|
||||
)
|
||||
->get();
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"require-dev": {
|
||||
"barryvdh/laravel-ide-helper": "*",
|
||||
"deptrac/deptrac": "^4.6",
|
||||
"fakerphp/faker": "^1.23",
|
||||
"infection/infection": "^0.32.7",
|
||||
"larastan/larastan": "*",
|
||||
|
||||
Generated
+427
-1
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "f6418ddc96f575de868a519b516c26d8",
|
||||
"content-hash": "b859d747b77450b0917b3a7ae30284aa",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -7279,6 +7279,91 @@
|
||||
],
|
||||
"time": "2024-05-06T16:37:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "deptrac/deptrac",
|
||||
"version": "4.6.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/deptrac/deptrac.git",
|
||||
"reference": "6ff20dec210f119a4ddebdf8e28603689f34eb67"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/deptrac/deptrac/zipball/6ff20dec210f119a4ddebdf8e28603689f34eb67",
|
||||
"reference": "6ff20dec210f119a4ddebdf8e28603689f34eb67",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer/xdebug-handler": "^3.0",
|
||||
"jetbrains/phpstorm-stubs": "2024.3 || 2025.3 || 2026.1",
|
||||
"nikic/php-parser": "^5",
|
||||
"php": "^8.2",
|
||||
"phpdocumentor/graphviz": "^2.1",
|
||||
"phpdocumentor/type-resolver": "^1.9.0 || ^2.0.0",
|
||||
"phpstan/phpdoc-parser": "^1.5.0 || ^2.1.0",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"psr/container": "^2.0",
|
||||
"psr/event-dispatcher": "^1.0",
|
||||
"symfony/config": "^6.4 || ^7.4 || ^8.0",
|
||||
"symfony/console": "^6.4 || ^7.4 || ^8.0",
|
||||
"symfony/dependency-injection": "^6.4 || ^7.4 || ^8.0",
|
||||
"symfony/event-dispatcher": "^6.4 || ^7.4 || ^8.0",
|
||||
"symfony/event-dispatcher-contracts": "^3.4",
|
||||
"symfony/filesystem": "^6.4 || ^7.4 || ^8.0",
|
||||
"symfony/finder": "^6.4 || ^7.4 || ^8.0",
|
||||
"symfony/yaml": "^6.4 || ^7.4 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"bamarni/composer-bin-plugin": "^1.8",
|
||||
"ergebnis/composer-normalize": "^2.45",
|
||||
"ext-libxml": "*",
|
||||
"symfony/stopwatch": "^6.4 || ^7.4 || ^8.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-dom": "For using the JUnit output formatter"
|
||||
},
|
||||
"bin": [
|
||||
"deptrac"
|
||||
],
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"bamarni-bin": {
|
||||
"bin-links": false,
|
||||
"forward-command": true,
|
||||
"target-directory": "tools"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Deptrac\\Deptrac\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Tim Glabisch"
|
||||
},
|
||||
{
|
||||
"name": "Simon Mönch"
|
||||
},
|
||||
{
|
||||
"name": "Denis Brumann"
|
||||
}
|
||||
],
|
||||
"description": "Deptrac is a static code analysis tool that helps to enforce rules for dependencies between software layers.",
|
||||
"keywords": [
|
||||
"dev",
|
||||
"static analysis"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/deptrac/deptrac/issues",
|
||||
"source": "https://github.com/deptrac/deptrac/tree/4.6.1"
|
||||
},
|
||||
"time": "2026-05-13T08:23:06+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/deprecations",
|
||||
"version": "1.1.6",
|
||||
@@ -8042,6 +8127,50 @@
|
||||
},
|
||||
"time": "2025-03-19T14:43:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "jetbrains/phpstorm-stubs",
|
||||
"version": "v2026.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/JetBrains/phpstorm-stubs",
|
||||
"reference": "2cdd054c4109dfb76667c9198bf9427606354243"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/2cdd054c4109dfb76667c9198bf9427606354243",
|
||||
"reference": "2cdd054c4109dfb76667c9198bf9427606354243",
|
||||
"shasum": ""
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^v3.86",
|
||||
"nikic/php-parser": "^v5.6",
|
||||
"phpdocumentor/reflection-docblock": "^5.6",
|
||||
"phpunit/phpunit": "^12.3"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"PhpStormStubsMap.php"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"description": "PHP runtime & extensions header files for PhpStorm",
|
||||
"homepage": "https://www.jetbrains.com/phpstorm",
|
||||
"keywords": [
|
||||
"autocomplete",
|
||||
"code",
|
||||
"inference",
|
||||
"inspection",
|
||||
"jetbrains",
|
||||
"phpstorm",
|
||||
"stubs",
|
||||
"type"
|
||||
],
|
||||
"time": "2026-02-19T20:12:01+00:00"
|
||||
},
|
||||
{
|
||||
"name": "justinrainbow/json-schema",
|
||||
"version": "6.8.2",
|
||||
@@ -9674,6 +9803,59 @@
|
||||
},
|
||||
"time": "2022-02-21T01:04:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpdocumentor/graphviz",
|
||||
"version": "2.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phpDocumentor/GraphViz.git",
|
||||
"reference": "115999dc7f31f2392645aa825a94a6b165e1cedf"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpDocumentor/GraphViz/zipball/115999dc7f31f2392645aa825a94a6b165e1cedf",
|
||||
"reference": "115999dc7f31f2392645aa825a94a6b165e1cedf",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-simplexml": "*",
|
||||
"mockery/mockery": "^1.2",
|
||||
"phpstan/phpstan": "^0.12",
|
||||
"phpunit/phpunit": "^8.2 || ^9.2",
|
||||
"psalm/phar": "^4.15"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"phpDocumentor\\GraphViz\\": "src/phpDocumentor/GraphViz",
|
||||
"phpDocumentor\\GraphViz\\PHPStan\\": "./src/phpDocumentor/PHPStan"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Mike van Riel",
|
||||
"email": "mike.vanriel@naenius.com"
|
||||
}
|
||||
],
|
||||
"description": "Wrapper for Graphviz",
|
||||
"support": {
|
||||
"issues": "https://github.com/phpDocumentor/GraphViz/issues",
|
||||
"source": "https://github.com/phpDocumentor/GraphViz/tree/2.1.0"
|
||||
},
|
||||
"time": "2021-12-13T19:03:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpdocumentor/reflection-common",
|
||||
"version": "2.2.0",
|
||||
@@ -12674,6 +12856,169 @@
|
||||
],
|
||||
"time": "2024-10-20T05:08:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/config",
|
||||
"version": "v7.4.10",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/config.git",
|
||||
"reference": "d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/config/zipball/d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57",
|
||||
"reference": "d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"symfony/deprecation-contracts": "^2.5|^3",
|
||||
"symfony/filesystem": "^7.1|^8.0",
|
||||
"symfony/polyfill-ctype": "~1.8"
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/finder": "<6.4",
|
||||
"symfony/service-contracts": "<2.5"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/event-dispatcher": "^6.4|^7.0|^8.0",
|
||||
"symfony/finder": "^6.4|^7.0|^8.0",
|
||||
"symfony/messenger": "^6.4|^7.0|^8.0",
|
||||
"symfony/service-contracts": "^2.5|^3",
|
||||
"symfony/yaml": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Config\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Helps you find, load, combine, autofill and validate configuration values of any kind",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/config/tree/v7.4.10"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-03T14:20:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/dependency-injection",
|
||||
"version": "v7.4.10",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/dependency-injection.git",
|
||||
"reference": "4eb0d9dfa9d4f7c59216baf49b3ed6b1fb72293d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/dependency-injection/zipball/4eb0d9dfa9d4f7c59216baf49b3ed6b1fb72293d",
|
||||
"reference": "4eb0d9dfa9d4f7c59216baf49b3ed6b1fb72293d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"psr/container": "^1.1|^2.0",
|
||||
"symfony/deprecation-contracts": "^2.5|^3",
|
||||
"symfony/service-contracts": "^3.6",
|
||||
"symfony/var-exporter": "^6.4.20|^7.2.5|^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"ext-psr": "<1.1|>=2",
|
||||
"symfony/config": "<6.4",
|
||||
"symfony/finder": "<6.4",
|
||||
"symfony/yaml": "<6.4"
|
||||
},
|
||||
"provide": {
|
||||
"psr/container-implementation": "1.1|2.0",
|
||||
"symfony/service-implementation": "1.1|2.0|3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/config": "^6.4|^7.0|^8.0",
|
||||
"symfony/expression-language": "^6.4|^7.0|^8.0",
|
||||
"symfony/yaml": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\DependencyInjection\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Allows you to standardize and centralize the way objects are constructed in your application",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/dependency-injection/tree/v7.4.10"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-06T11:55:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/filesystem",
|
||||
"version": "v7.4.9",
|
||||
@@ -12744,6 +13089,87 @@
|
||||
],
|
||||
"time": "2026-04-18T13:18:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/var-exporter",
|
||||
"version": "v7.4.9",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/var-exporter.git",
|
||||
"reference": "22e03a49c95ef054a43601cd159b222bfab1c701"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/var-exporter/zipball/22e03a49c95ef054a43601cd159b222bfab1c701",
|
||||
"reference": "22e03a49c95ef054a43601cd159b222bfab1c701",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"symfony/deprecation-contracts": "^2.5|^3"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/property-access": "^6.4|^7.0|^8.0",
|
||||
"symfony/serializer": "^6.4|^7.0|^8.0",
|
||||
"symfony/var-dumper": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\VarExporter\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Allows exporting any serializable PHP data structure to plain PHP code",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"clone",
|
||||
"construct",
|
||||
"export",
|
||||
"hydrate",
|
||||
"instantiate",
|
||||
"lazy-loading",
|
||||
"proxy",
|
||||
"serialize"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/var-exporter/tree/v7.4.9"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-18T13:18:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/yaml",
|
||||
"version": "v7.4.10",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Plan 6 (C9) — subject-level regions.
|
||||
*
|
||||
* +1 колонка projects.regions INT[] (1..89 коды субъектов РФ; пустой массив = вся РФ).
|
||||
* +1 GIN-индекс idx_projects_regions для outbound regions queries.
|
||||
* region_mask/region_mode остаются (dual-write) — удаление в Plan 6.5.
|
||||
*
|
||||
* Guard'ы: migrate:fresh грузит schema.sql v8.22 (где delta уже есть) до миграций,
|
||||
* поэтому каждый кусок применяется только при отсутствии (как Sprint 4 миграция).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn('projects', 'regions')) {
|
||||
DB::statement("ALTER TABLE projects ADD COLUMN regions INT[] NOT NULL DEFAULT '{}'::INT[]");
|
||||
}
|
||||
|
||||
DB::statement('CREATE INDEX IF NOT EXISTS idx_projects_regions ON projects USING GIN (regions)');
|
||||
|
||||
DB::statement(
|
||||
'COMMENT ON COLUMN projects.regions IS '
|
||||
."'Subject-level region filter (1..89 коды субъектов РФ). Пустой массив = вся РФ. Plan 6 (v8.22).'"
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('DROP INDEX IF EXISTS idx_projects_regions');
|
||||
|
||||
if (Schema::hasColumn('projects', 'regions')) {
|
||||
Schema::table('projects', fn ($table) => $table->dropColumn('regions'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Воронка статусов 14 → 5 (редизайн «Сделки» 2026-05-17).
|
||||
*
|
||||
* Новые 5: new / viewed / in_progress / won / lost. Slug'и `new` и `viewed`
|
||||
* сохраняются (RouteSupplierLeadJob / DealController@store default'ят 'new').
|
||||
* Ремап старых 14 → 5 в deals.status и import_unknown_statuses.mapped_to_slug
|
||||
* перед DELETE устаревших lead_statuses (FK-safe). tenant_status_overrides
|
||||
* со старыми slug'ами удаляются (кастомные ярлыки схлопнутых статусов
|
||||
* обсолетны + исключает PK-коллизию при ремапе).
|
||||
*
|
||||
* На migrate:fresh schema.sql уже сеет 5 — UPDATE/DELETE здесь no-op.
|
||||
* down() необратима (схлопывание lossy).
|
||||
*
|
||||
* Спека: docs/superpowers/specs/2026-05-17-deals-page-redesign-design.md §3.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
/** Старый slug → новый. new/viewed не меняются (отсутствуют в карте). */
|
||||
private const REMAP = [
|
||||
'worked' => 'in_progress', 'base' => 'in_progress', 'missed' => 'in_progress',
|
||||
'negotiations' => 'in_progress', 'waiting_payment' => 'in_progress',
|
||||
'partnership' => 'in_progress', 'test_drive' => 'in_progress', 'hot' => 'in_progress',
|
||||
'replacement' => 'in_progress', 'final_missed' => 'in_progress',
|
||||
'paid' => 'won', 'closed' => 'lost',
|
||||
];
|
||||
|
||||
private const KEEP = ['new', 'viewed', 'in_progress', 'won', 'lost'];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
DB::transaction(function () {
|
||||
// 1) Новые slug'и обязаны существовать до ремапа FK-ссылок.
|
||||
DB::table('lead_statuses')->upsert([
|
||||
['slug' => 'new', 'name_ru' => 'Новая сделка', 'is_system' => true, 'sort_order' => 1, 'color_hex' => '#3B82F6'],
|
||||
['slug' => 'viewed', 'name_ru' => 'Просмотрено', 'is_system' => true, 'sort_order' => 2, 'color_hex' => '#8B5CF6'],
|
||||
['slug' => 'in_progress', 'name_ru' => 'В работе', 'is_system' => true, 'sort_order' => 3, 'color_hex' => '#06B6D4'],
|
||||
['slug' => 'won', 'name_ru' => 'Сделка', 'is_system' => true, 'sort_order' => 4, 'color_hex' => '#10B981'],
|
||||
['slug' => 'lost', 'name_ru' => 'Не реализовано', 'is_system' => true, 'sort_order' => 5, 'color_hex' => '#6B7280'],
|
||||
], ['slug'], ['name_ru', 'is_system', 'sort_order', 'color_hex']);
|
||||
|
||||
// 2) Ремап ссылок на старые slug'и.
|
||||
foreach (self::REMAP as $old => $new) {
|
||||
DB::table('deals')->where('status', $old)->update(['status' => $new]);
|
||||
DB::table('import_unknown_statuses')->where('mapped_to_slug', $old)->update(['mapped_to_slug' => $new]);
|
||||
}
|
||||
|
||||
// 3) Обсолетные кастомные ярлыки статусов — удалить (FK на lead_statuses).
|
||||
DB::table('tenant_status_overrides')->whereNotIn('status_slug', self::KEEP)->delete();
|
||||
|
||||
// 4) Удалить устаревшие статусы (все FK-ссылки перенаправлены).
|
||||
DB::table('lead_statuses')->whereNotIn('slug', self::KEEP)->delete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
throw new RuntimeException('Воронка 14→5 необратима (схлопывание статусов lossy).');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
deptrac:
|
||||
paths:
|
||||
- ./app
|
||||
layers:
|
||||
- name: Controller
|
||||
collectors: [{ type: directory, value: app/Http/Controllers/.* }]
|
||||
- name: Request
|
||||
collectors: [{ type: directory, value: app/Http/Requests/.* }]
|
||||
- name: Resource
|
||||
collectors: [{ type: directory, value: app/Http/Resources/.* }]
|
||||
- name: Middleware
|
||||
collectors: [{ type: directory, value: app/Http/Middleware/.* }]
|
||||
- name: Service
|
||||
collectors: [{ type: directory, value: app/Services/.* }]
|
||||
- name: Job
|
||||
collectors: [{ type: directory, value: app/Jobs/.* }]
|
||||
- name: Console
|
||||
collectors: [{ type: directory, value: app/Console/.* }]
|
||||
- name: Repository
|
||||
collectors: [{ type: directory, value: app/Repositories/.* }]
|
||||
- name: Model
|
||||
collectors: [{ type: directory, value: app/Models/.* }]
|
||||
- name: Mail
|
||||
collectors: [{ type: directory, value: app/Mail/.* }]
|
||||
- name: Rule
|
||||
collectors: [{ type: directory, value: app/Rules/.* }]
|
||||
- name: Exception
|
||||
collectors: [{ type: directory, value: app/Exceptions/.* }]
|
||||
- name: Provider
|
||||
collectors: [{ type: directory, value: app/Providers/.* }]
|
||||
ruleset:
|
||||
# Conservative ruleset — enforces only the architecturally-wrong directions
|
||||
# (inward/upward deps). Whatever current code violates is captured by the
|
||||
# baseline (deptrac.baseline.yaml); this gate then catches only NEW drift.
|
||||
Controller: [Service, Request, Resource, Model, Job, Mail, Repository, Rule, Exception]
|
||||
Middleware: [Service, Model, Exception]
|
||||
Service: [Service, Model, Repository, Job, Mail, Rule, Exception]
|
||||
Job: [Service, Model, Repository, Mail, Exception]
|
||||
Console: [Service, Model, Repository, Job, Mail, Exception]
|
||||
Repository: [Model, Exception]
|
||||
Request: [Rule, Model]
|
||||
Resource: [Model]
|
||||
Rule: [Model]
|
||||
Mail: [Model]
|
||||
Model: []
|
||||
Provider: [Controller, Service, Job, Console, Repository, Model, Mail, Middleware, Request, Resource, Rule, Exception]
|
||||
+291
-9
@@ -54,18 +54,132 @@ parameters:
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/AdminTenantsController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Deal\:\:\$next_reminder_at\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/DealController.php
|
||||
|
||||
-
|
||||
message: '#^Using nullsafe method call on non\-nullable type Illuminate\\Support\\Carbon\. Use \-\> instead\.$#'
|
||||
identifier: nullsafe.neverNull
|
||||
count: 5
|
||||
path: app/Http/Controllers/Api/DealController.php
|
||||
|
||||
-
|
||||
message: '#^Expression on left side of \?\? is not nullable\.$#'
|
||||
identifier: nullCoalesce.expr
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/DealExportController.php
|
||||
|
||||
-
|
||||
message: '#^Using nullsafe method call on non\-nullable type Illuminate\\Support\\Carbon\. Use \-\> instead\.$#'
|
||||
identifier: nullsafe.neverNull
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/DealExportController.php
|
||||
|
||||
-
|
||||
message: '#^Using nullsafe property access "\?\-\>name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
|
||||
identifier: nullsafe.neverNull
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/DealExportController.php
|
||||
|
||||
-
|
||||
message: '#^Cannot call method toIso8601String\(\) on null\.$#'
|
||||
identifier: method.nonObject
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/ImpersonationController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$dry_run\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/ImportController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$error_message\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/ImportController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$filename\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/ImportController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$finished_at\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/ImportController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$rows_added\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/ImportController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$rows_skipped\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/ImportController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$rows_total\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/ImportController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$rows_updated\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/ImportController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$started_at\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/ImportController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$status\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/ImportController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$tenant_id\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/ImportController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$unknown_statuses_count\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/ImportController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportUnknownStatus\:\:\$occurrences\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/ImportController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportUnknownStatus\:\:\$status_ru\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/ImportController.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#1 \$callback of method Illuminate\\Database\\Eloquent\\Collection\<int,App\\Models\\ImportUnknownStatus\>\:\:map\(\) contains unresolvable type\.$#'
|
||||
identifier: argument.unresolvableType
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/ImportController.php
|
||||
|
||||
-
|
||||
message: '#^Using nullsafe method call on non\-nullable type Illuminate\\Support\\Carbon\. Use \-\> instead\.$#'
|
||||
identifier: nullsafe.neverNull
|
||||
@@ -78,12 +192,48 @@ parameters:
|
||||
count: 1
|
||||
path: app/Http/Middleware/SetTenantContext.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$file_path\.$#'
|
||||
identifier: property.notFound
|
||||
count: 3
|
||||
path: app/Jobs/ImportLeadsJob.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$user_id\.$#'
|
||||
identifier: property.notFound
|
||||
count: 3
|
||||
path: app/Jobs/ImportLeadsJob.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#1 \$array \(array\{string\}\) of array_values is already a list, call has no effect\.$#'
|
||||
identifier: arrayValues.list
|
||||
count: 1
|
||||
path: app/Jobs/Supplier/SyncSupplierProjectsJob.php
|
||||
|
||||
-
|
||||
message: '#^Using nullsafe property access "\?\-\>name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
|
||||
identifier: nullsafe.neverNull
|
||||
count: 2
|
||||
path: app/Mail/NewLeadNotification.php
|
||||
|
||||
-
|
||||
message: '#^PHPDoc tag @mixin contains unknown class App\\Models\\IdeHelperImportLog\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: app/Models/ImportLog.php
|
||||
|
||||
-
|
||||
message: '#^PHPDoc tag @mixin contains unknown class App\\Models\\IdeHelperImportUnknownStatus\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: app/Models/ImportUnknownStatus.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$dry_run\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Services/Import/HistoricalImportService.php
|
||||
|
||||
-
|
||||
message: '#^Call to function is_array\(\) with array\<mixed\> will always evaluate to true\.$#'
|
||||
identifier: function.alreadyNarrowedType
|
||||
@@ -159,7 +309,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 6
|
||||
count: 9
|
||||
path: tests/Feature/Admin/AdminPricingTiersControllerTest.php
|
||||
|
||||
-
|
||||
@@ -285,7 +435,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 14
|
||||
count: 15
|
||||
path: tests/Feature/Api/ProjectBulkActionsTest.php
|
||||
|
||||
-
|
||||
@@ -711,7 +861,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 25
|
||||
count: 10
|
||||
path: tests/Feature/DealCreateTest.php
|
||||
|
||||
-
|
||||
@@ -756,6 +906,42 @@ parameters:
|
||||
count: 2
|
||||
path: tests/Feature/DealDestroyTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
|
||||
identifier: property.notFound
|
||||
count: 5
|
||||
path: tests/Feature/DealExportTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 6
|
||||
path: tests/Feature/DealExportTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealExportTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealExportTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:post\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 4
|
||||
path: tests/Feature/DealExportTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/DealExportTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$manager\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -765,13 +951,13 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$otherTenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 7
|
||||
count: 10
|
||||
path: tests/Feature/DealIndexTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
|
||||
identifier: property.notFound
|
||||
count: 26
|
||||
count: 38
|
||||
path: tests/Feature/DealIndexTest.php
|
||||
|
||||
-
|
||||
@@ -783,7 +969,7 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 30
|
||||
count: 41
|
||||
path: tests/Feature/DealIndexTest.php
|
||||
|
||||
-
|
||||
@@ -801,7 +987,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 21
|
||||
count: 29
|
||||
path: tests/Feature/DealIndexTest.php
|
||||
|
||||
-
|
||||
@@ -972,6 +1158,12 @@ parameters:
|
||||
count: 9
|
||||
path: tests/Feature/DealUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/DemoSeederTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -1008,6 +1200,18 @@ parameters:
|
||||
count: 17
|
||||
path: tests/Feature/ImpersonationTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportUnknownStatus\:\:\$mapped_to_slug\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Import/HistoricalImportServiceTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportUnknownStatus\:\:\$occurrences\.$#'
|
||||
identifier: property.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Import/HistoricalImportServiceTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$service\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -1038,6 +1242,18 @@ parameters:
|
||||
count: 3
|
||||
path: tests/Feature/Import/ImportCompletedNotificationTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportUnknownStatus\:\:\$mapped_to_slug\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Import/ImportControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportUnknownStatus\:\:\$resolved_at\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Import/ImportControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -1068,6 +1284,42 @@ parameters:
|
||||
count: 5
|
||||
path: tests/Feature/Import/ImportControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$error_message\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Import/ImportLeadsJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$finished_at\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Import/ImportLeadsJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$rows_added\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Import/ImportLeadsJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$rows_skipped\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Import/ImportLeadsJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$status\.$#'
|
||||
identifier: property.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Import/ImportLeadsJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$unknown_statuses_count\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Import/ImportLeadsJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -1080,6 +1332,36 @@ parameters:
|
||||
count: 4
|
||||
path: tests/Feature/Import/ImportLeadsJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$dry_run\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Import/ImportModelsTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$entity_type\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Import/ImportModelsTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$mapping_config\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Import/ImportModelsTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$status\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Import/ImportModelsTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\ImportUnknownStatus\:\:\$status_ru\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Import/ImportModelsTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -1209,13 +1491,13 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 9
|
||||
count: 12
|
||||
path: tests/Feature/Plan5/Projects/ProjectsStoreTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 6
|
||||
count: 8
|
||||
path: tests/Feature/Plan5/Projects/ProjectsUpdateTest.php
|
||||
|
||||
-
|
||||
|
||||
@@ -130,6 +130,26 @@ export async function exportDealsXlsx(payload: Omit<ExportDealsPayload, 'format'
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface ExportDealsByRangePayload {
|
||||
tenant_id: number;
|
||||
received_from?: string;
|
||||
received_to?: string;
|
||||
format: 'csv' | 'xlsx';
|
||||
}
|
||||
|
||||
/**
|
||||
* Экспорт сделок по диапазону дат поставки. format='xlsx' → Blob, 'csv' → строка.
|
||||
*/
|
||||
export async function exportDealsByRange(payload: ExportDealsByRangePayload): Promise<Blob | string> {
|
||||
await ensureCsrfCookie();
|
||||
if (payload.format === 'xlsx') {
|
||||
const { data } = await apiClient.post<Blob>('/api/deals/export', payload, { responseType: 'blob' });
|
||||
return data;
|
||||
}
|
||||
const { data } = await apiClient.post<string>('/api/deals/export', payload, { responseType: 'text' });
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface ApiDeal {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
@@ -142,6 +162,10 @@ export interface ApiDeal {
|
||||
manager_name: string | null;
|
||||
manager_initials: string | null;
|
||||
received_at: string | null;
|
||||
comment: string | null;
|
||||
city: string | null;
|
||||
project_signal_type: string | null;
|
||||
next_reminder_at: string | null;
|
||||
}
|
||||
|
||||
export interface ApiDealEvent {
|
||||
@@ -175,6 +199,9 @@ export interface ListDealsParams {
|
||||
projectId?: number;
|
||||
managerId?: number;
|
||||
search?: string;
|
||||
/** Диапазон дат поставки (received_at). ISO-дата 'YYYY-MM-DD'. */
|
||||
receivedFrom?: string;
|
||||
receivedTo?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
/** «Корзина» — вернуть ТОЛЬКО soft-deleted сделки. */
|
||||
@@ -196,6 +223,8 @@ export async function listDeals(params: ListDealsParams): Promise<ListDealsRespo
|
||||
project_id: params.projectId,
|
||||
manager_id: params.managerId,
|
||||
search: params.search,
|
||||
received_from: params.receivedFrom,
|
||||
received_to: params.receivedTo,
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
only_deleted: params.onlyDeleted ? 'true' : undefined,
|
||||
|
||||
@@ -24,11 +24,11 @@ import FunnelChart from './FunnelChart.vue';
|
||||
</v-app>
|
||||
</Variant>
|
||||
|
||||
<Variant title="концентрация на 'Оплачено'">
|
||||
<Variant title="концентрация на 'Сделка'">
|
||||
<v-app>
|
||||
<v-main class="story-pane">
|
||||
<v-container>
|
||||
<FunnelChart :counts="{ paid: 100, new: 5, viewed: 5, worked: 5 }" />
|
||||
<FunnelChart :counts="{ won: 100, new: 5, viewed: 5, in_progress: 5 }" />
|
||||
</v-container>
|
||||
</v-main>
|
||||
</v-app>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Воронка распределения лидов по 14 статусам.
|
||||
* Воронка распределения лидов по 5 статусам воронки.
|
||||
*
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_dashboard.html секция .panel
|
||||
* с #funnel-title (segmented bar + funnel-list).
|
||||
@@ -13,7 +13,7 @@
|
||||
* Рендер:
|
||||
* 1. Segmented horizontal bar — каждый сегмент пропорционален count'у статуса
|
||||
* и закрашен colorHex из lead_statuses.
|
||||
* 2. funnel-list — 14 строк с цветным dot + name + count, отсортированы по
|
||||
* 2. funnel-list — 5 строк с цветным dot + name + count, отсортированы по
|
||||
* убыванию count'а (как в handoff).
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
@@ -26,23 +26,14 @@ interface Props {
|
||||
|
||||
// Default counts инлайнятся в withDefaults — Vue SFC compiler требует чтобы
|
||||
// factory-функция в withDefaults не реферировала модуль-уровневые const'ы
|
||||
// (checkInvalidScopeReference). Mock-распределение ~247 лидов по 14 статусам.
|
||||
// (checkInvalidScopeReference). Mock-распределение ~190 лидов по 5 статусам.
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
counts: () => ({
|
||||
new: 18,
|
||||
viewed: 14,
|
||||
worked: 22,
|
||||
base: 9,
|
||||
missed: 16,
|
||||
negotiations: 11,
|
||||
waiting_payment: 7,
|
||||
partnership: 4,
|
||||
paid: 45,
|
||||
closed: 3,
|
||||
test_drive: 38,
|
||||
hot: 5,
|
||||
replacement: 5,
|
||||
final_missed: 39,
|
||||
new: 24,
|
||||
viewed: 18,
|
||||
in_progress: 96,
|
||||
won: 41,
|
||||
lost: 11,
|
||||
}),
|
||||
title: 'Воронка',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Тело панели деталей сделки (hero + параметры + комментарий + напоминания +
|
||||
* timeline). Извлечено из DealDetailDrawer (редизайн 2026-05-17) — общее тело
|
||||
* для overlay-дровера (Канбан) и inline-панели master-detail («Сделки»).
|
||||
*
|
||||
* Backend: GET /api/deals/{id}, PATCH /api/deals/{id}, GET /api/deals/{id}/events.
|
||||
*/
|
||||
import { computed, defineAsyncComponent, ref, watch } from 'vue';
|
||||
import type { MockDeal } from '../../composables/mockDeals';
|
||||
import { type DealEvent } from '../../composables/mockDealEvents';
|
||||
import { mapApiDealEvent } from '../../composables/dealsApiMapper';
|
||||
import * as dealsApi from '../../api/deals';
|
||||
import * as remindersApi from '../../api/reminders';
|
||||
import type { ApiReminder } from '../../api/reminders';
|
||||
import { useLeadStatusesStore } from '../../stores/leadStatuses';
|
||||
import DealDetailHero from './DealDetailHero.vue';
|
||||
import DealDetailTimeline from './DealDetailTimeline.vue';
|
||||
const ReminderDialog = defineAsyncComponent(() => import('../reminders/ReminderDialog.vue'));
|
||||
|
||||
const leadStatusesStore = useLeadStatusesStore();
|
||||
|
||||
const props = defineProps<{
|
||||
deal: MockDeal | null;
|
||||
tenantId?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const status = computed(() => {
|
||||
if (!props.deal) return null;
|
||||
return leadStatusesStore.findBySlug(props.deal.statusSlug);
|
||||
});
|
||||
|
||||
function formatCost(cost: number): string {
|
||||
return new Intl.NumberFormat('ru-RU').format(cost) + ' ₽';
|
||||
}
|
||||
|
||||
const events = ref<DealEvent[]>([]);
|
||||
const eventsLoading = ref(false);
|
||||
const eventsFetchError = ref(false);
|
||||
|
||||
const commentDraft = ref<string>('');
|
||||
const commentSaving = ref(false);
|
||||
const commentSaveError = ref(false);
|
||||
const commentToastOpen = ref(false);
|
||||
const commentToastText = ref('');
|
||||
|
||||
const reminders = ref<ApiReminder[]>([]);
|
||||
const remindersLoading = ref(false);
|
||||
const reminderDialogOpen = ref(false);
|
||||
|
||||
async function loadReminders() {
|
||||
if (!props.deal || !props.tenantId) {
|
||||
reminders.value = [];
|
||||
return;
|
||||
}
|
||||
remindersLoading.value = true;
|
||||
try {
|
||||
const res = await remindersApi.listReminders({ filter: 'active', dealId: props.deal.id });
|
||||
reminders.value = res.items;
|
||||
} catch {
|
||||
reminders.value = [];
|
||||
} finally {
|
||||
remindersLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function completeReminderInDrawer(id: number) {
|
||||
try {
|
||||
await remindersApi.completeReminder(id);
|
||||
reminders.value = reminders.value.filter((r) => r.id !== id);
|
||||
} catch {
|
||||
/* silent */
|
||||
}
|
||||
}
|
||||
|
||||
function onReminderSaved() {
|
||||
void loadReminders();
|
||||
}
|
||||
|
||||
function formatReminderTime(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const ms = new Date(iso).getTime() - Date.now();
|
||||
const min = Math.round(Math.abs(ms) / 60_000);
|
||||
const future = ms > 0;
|
||||
if (min < 60) return future ? `через ${min} мин` : `${min} мин назад`;
|
||||
const hr = Math.round(min / 60);
|
||||
if (hr < 24) return future ? `через ${hr} ч` : `${hr} ч назад`;
|
||||
const days = Math.round(hr / 24);
|
||||
return future ? `через ${days} д` : `${days} д назад`;
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
if (!props.deal || !props.tenantId) {
|
||||
events.value = [];
|
||||
commentDraft.value = '';
|
||||
return;
|
||||
}
|
||||
eventsLoading.value = true;
|
||||
eventsFetchError.value = false;
|
||||
try {
|
||||
const res = await dealsApi.getDeal(props.deal.id, props.tenantId);
|
||||
events.value = res.events.map((e) => mapApiDealEvent(e));
|
||||
commentDraft.value = res.deal.comment ?? '';
|
||||
} catch {
|
||||
eventsFetchError.value = true;
|
||||
events.value = [];
|
||||
commentDraft.value = '';
|
||||
} finally {
|
||||
eventsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveComment() {
|
||||
if (!props.deal || !props.tenantId) return;
|
||||
commentSaving.value = true;
|
||||
commentSaveError.value = false;
|
||||
try {
|
||||
await dealsApi.updateDeal(props.deal.id, {
|
||||
tenant_id: props.tenantId,
|
||||
comment: commentDraft.value || null,
|
||||
});
|
||||
commentToastText.value = 'Комментарий сохранён.';
|
||||
commentToastOpen.value = true;
|
||||
await loadEvents();
|
||||
} catch {
|
||||
commentSaveError.value = true;
|
||||
commentToastText.value = 'Не удалось сохранить — попробуйте позже.';
|
||||
commentToastOpen.value = true;
|
||||
} finally {
|
||||
commentSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка при появлении/смене сделки. Компонент смонтирован всегда — тело (<div v-if="deal">) рендерится только при deal != null.
|
||||
watch(
|
||||
() => [props.deal?.id, props.tenantId] as const,
|
||||
() => {
|
||||
if (props.deal) {
|
||||
loadEvents();
|
||||
void loadReminders();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
events, eventsLoading, eventsFetchError, loadEvents,
|
||||
commentDraft, commentSaving, commentSaveError, commentToastOpen, commentToastText, saveComment,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="deal" class="drawer-content">
|
||||
<DealDetailHero :deal="deal" :status="status" @close="emit('close')" />
|
||||
|
||||
<v-divider />
|
||||
|
||||
<section class="section pa-5">
|
||||
<h3 class="section-title text-subtitle-2 mb-3">Параметры</h3>
|
||||
<dl class="params">
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Проект</dt>
|
||||
<dd class="text-body-2">{{ deal.project }}</dd>
|
||||
</div>
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Стоимость лида</dt>
|
||||
<dd class="text-body-2 num">{{ formatCost(deal.cost) }}</dd>
|
||||
</div>
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Менеджер</dt>
|
||||
<dd class="text-body-2">
|
||||
<v-avatar size="20" color="secondary" class="mr-1">
|
||||
<span class="text-caption">{{ deal.manager.initials }}</span>
|
||||
</v-avatar>
|
||||
{{ deal.manager.name }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Источник</dt>
|
||||
<dd class="text-body-2 link">Я.Директ → landing-1</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<section v-if="tenantId" class="section pa-5" data-testid="comment-section">
|
||||
<h3 class="section-title text-subtitle-2 mb-3">Комментарий</h3>
|
||||
<v-textarea
|
||||
v-model="commentDraft"
|
||||
placeholder="Заметка менеджера…"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
auto-grow
|
||||
rows="3"
|
||||
hide-details
|
||||
counter="5000"
|
||||
data-testid="comment-textarea"
|
||||
/>
|
||||
<div class="d-flex ga-2 mt-2 justify-end">
|
||||
<v-btn
|
||||
:loading="commentSaving"
|
||||
color="primary"
|
||||
size="small"
|
||||
prepend-icon="mdi-content-save-outline"
|
||||
data-testid="save-comment-btn"
|
||||
@click="saveComment"
|
||||
>
|
||||
Сохранить
|
||||
</v-btn>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<v-divider v-if="tenantId" />
|
||||
|
||||
<section v-if="tenantId && deal" class="section pa-5" data-testid="reminders-section">
|
||||
<div class="d-flex justify-space-between align-center mb-3">
|
||||
<h3 class="section-title text-subtitle-2 mb-0">Напоминания</h3>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
prepend-icon="mdi-plus"
|
||||
data-testid="add-reminder-btn"
|
||||
@click="reminderDialogOpen = true"
|
||||
>
|
||||
Создать
|
||||
</v-btn>
|
||||
</div>
|
||||
<div v-if="reminders.length === 0 && !remindersLoading" class="text-caption text-medium-emphasis">
|
||||
Нет активных напоминаний.
|
||||
</div>
|
||||
<ul v-else class="reminders-list">
|
||||
<li v-for="r in reminders" :key="r.id" class="reminder-row" data-testid="drawer-reminder-item">
|
||||
<v-btn
|
||||
icon="mdi-check-circle-outline"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
density="comfortable"
|
||||
:data-testid="`drawer-complete-${r.id}`"
|
||||
@click="completeReminderInDrawer(r.id)"
|
||||
/>
|
||||
<div class="reminder-body">
|
||||
<div class="reminder-text">{{ r.text || 'Без описания' }}</div>
|
||||
<div class="reminder-meta text-caption text-medium-emphasis">
|
||||
{{ formatReminderTime(r.remind_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<v-divider v-if="tenantId && deal" />
|
||||
|
||||
<DealDetailTimeline :events="events" :events-fetch-error="eventsFetchError" />
|
||||
|
||||
<v-snackbar
|
||||
v-model="commentToastOpen"
|
||||
:timeout="3000"
|
||||
:color="commentSaveError ? 'warning' : undefined"
|
||||
data-testid="comment-toast"
|
||||
location="bottom right"
|
||||
>
|
||||
{{ commentToastText }}
|
||||
</v-snackbar>
|
||||
|
||||
<ReminderDialog
|
||||
v-if="tenantId && deal"
|
||||
v-model="reminderDialogOpen"
|
||||
:deal-id="deal.id"
|
||||
@saved="onReminderSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drawer-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
color: #081319;
|
||||
}
|
||||
.params {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
.param dt {
|
||||
font-size: 11px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.param dd {
|
||||
margin: 0;
|
||||
color: #081319;
|
||||
}
|
||||
.param .link {
|
||||
color: #0f6e56;
|
||||
cursor: pointer;
|
||||
}
|
||||
.param .link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
.reminders-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.reminder-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e8e3d6;
|
||||
border-radius: 6px;
|
||||
background: #fdfaf3;
|
||||
}
|
||||
.reminder-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.reminder-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: #081319;
|
||||
}
|
||||
.reminder-meta {
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,7 @@ const open1 = ref(true);
|
||||
const open2 = ref(true);
|
||||
|
||||
const dealNew = MOCK_DEALS.find((d) => d.statusSlug === 'new')!;
|
||||
const dealPaid = MOCK_DEALS.find((d) => d.statusSlug === 'paid')!;
|
||||
const dealWon = MOCK_DEALS.find((d) => d.statusSlug === 'won')!;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -20,10 +20,10 @@ const dealPaid = MOCK_DEALS.find((d) => d.statusSlug === 'paid')!;
|
||||
</v-app>
|
||||
</Variant>
|
||||
|
||||
<Variant title="paid status">
|
||||
<Variant title="won status">
|
||||
<v-app>
|
||||
<v-main class="story-main">
|
||||
<DealDetailDrawer v-model:open="open2" :deal="dealPaid" />
|
||||
<DealDetailDrawer v-model:open="open2" :deal="dealWon" />
|
||||
</v-main>
|
||||
</v-app>
|
||||
</Variant>
|
||||
|
||||
@@ -1,43 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Правая панель с деталями сделки. Открывается при click на строку в DealsView
|
||||
* или на карточку в KanbanView.
|
||||
*
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_deal_card.html.
|
||||
* MVP: hero (имя + телефон + статус-chip + close), параметры (Проект/Стоимость/
|
||||
* Источник/Email), Activity timeline (5-7 событий).
|
||||
*
|
||||
* Не входит в этот коммит:
|
||||
* - Редактирование параметров (input-fields + save).
|
||||
* - Смена статуса через dropdown (на Канбане — через DnD).
|
||||
* - Tag management, manager assignment, reminders, comment/templates —
|
||||
* отдельные секции, отдельные коммиты.
|
||||
*
|
||||
* Backend:
|
||||
* - GET /api/deals/{id} — full detail with events.
|
||||
* - PATCH /api/deals/{id} — частичное обновление полей.
|
||||
* - GET /api/deals/{id}/events — `activity_log` фильтр по deal_id.
|
||||
* Обёртка панели деталей сделки. `inline=false` (по умолчанию) — overlay
|
||||
* v-navigation-drawer (Канбан). `inline=true` — боковая панель master-detail
|
||||
* для страницы «Сделки» (список сжимается, панель встаёт рядом, не перекрывает).
|
||||
* Тело — общий DealDetailBody.vue.
|
||||
*/
|
||||
import { computed, defineAsyncComponent, ref, watch } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import type { MockDeal } from '../../composables/mockDeals';
|
||||
import { type DealEvent } from '../../composables/mockDealEvents';
|
||||
import { mapApiDealEvent } from '../../composables/dealsApiMapper';
|
||||
import * as dealsApi from '../../api/deals';
|
||||
import * as remindersApi from '../../api/reminders';
|
||||
import type { ApiReminder } from '../../api/reminders';
|
||||
import { useLeadStatusesStore } from '../../stores/leadStatuses';
|
||||
import DealDetailHero from './DealDetailHero.vue';
|
||||
import DealDetailTimeline from './DealDetailTimeline.vue';
|
||||
// Sprint 2 Phase B / O-perf-06: ReminderDialog гейтится через v-model — chunk-split.
|
||||
const ReminderDialog = defineAsyncComponent(() => import('../reminders/ReminderDialog.vue'));
|
||||
import DealDetailBody from './DealDetailBody.vue';
|
||||
|
||||
const leadStatusesStore = useLeadStatusesStore();
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
deal: MockDeal | null;
|
||||
tenantId?: number;
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
open: boolean;
|
||||
deal: MockDeal | null;
|
||||
tenantId?: number;
|
||||
inline?: boolean;
|
||||
}>(),
|
||||
{ inline: false },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ 'update:open': [value: boolean] }>();
|
||||
|
||||
@@ -46,265 +26,24 @@ const drawerOpen = computed({
|
||||
set: (v) => emit('update:open', v),
|
||||
});
|
||||
|
||||
const status = computed(() => {
|
||||
if (!props.deal) return null;
|
||||
return leadStatusesStore.findBySlug(props.deal.statusSlug);
|
||||
});
|
||||
|
||||
function formatCost(cost: number): string {
|
||||
return new Intl.NumberFormat('ru-RU').format(cost) + ' ₽';
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
// Activity timeline: при наличии tenant_id делаем GET /api/deals/{id} и
|
||||
// показываем реальные events. На fail / без tenant_id — events пуст + eventsFetchError.
|
||||
const events = ref<DealEvent[]>([]);
|
||||
const eventsLoading = ref(false);
|
||||
const eventsFetchError = ref(false);
|
||||
|
||||
// Comment editor — редактирование текущего комментария сделки.
|
||||
const commentDraft = ref<string>('');
|
||||
const commentSaving = ref(false);
|
||||
const commentSaveError = ref(false);
|
||||
const commentToastOpen = ref(false);
|
||||
const commentToastText = ref('');
|
||||
|
||||
// Reminders на сделку — отдельная секция с inline-create + список.
|
||||
const reminders = ref<ApiReminder[]>([]);
|
||||
const remindersLoading = ref(false);
|
||||
const reminderDialogOpen = ref(false);
|
||||
|
||||
async function loadReminders() {
|
||||
if (!props.deal || !props.tenantId) {
|
||||
reminders.value = [];
|
||||
return;
|
||||
}
|
||||
remindersLoading.value = true;
|
||||
try {
|
||||
const res = await remindersApi.listReminders({ filter: 'active', dealId: props.deal.id });
|
||||
reminders.value = res.items;
|
||||
} catch {
|
||||
reminders.value = [];
|
||||
} finally {
|
||||
remindersLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function completeReminderInDrawer(id: number) {
|
||||
try {
|
||||
await remindersApi.completeReminder(id);
|
||||
reminders.value = reminders.value.filter((r) => r.id !== id);
|
||||
} catch {
|
||||
/* silent */
|
||||
}
|
||||
}
|
||||
|
||||
function onReminderSaved() {
|
||||
void loadReminders();
|
||||
}
|
||||
|
||||
function formatReminderTime(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const ms = new Date(iso).getTime() - Date.now();
|
||||
const min = Math.round(Math.abs(ms) / 60_000);
|
||||
const future = ms > 0;
|
||||
if (min < 60) return future ? `через ${min} мин` : `${min} мин назад`;
|
||||
const hr = Math.round(min / 60);
|
||||
if (hr < 24) return future ? `через ${hr} ч` : `${hr} ч назад`;
|
||||
const days = Math.round(hr / 24);
|
||||
return future ? `через ${days} д` : `${days} д назад`;
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
if (!props.deal || !props.tenantId) {
|
||||
events.value = [];
|
||||
commentDraft.value = '';
|
||||
return;
|
||||
}
|
||||
eventsLoading.value = true;
|
||||
eventsFetchError.value = false;
|
||||
try {
|
||||
const res = await dealsApi.getDeal(props.deal.id, props.tenantId);
|
||||
events.value = res.events.map((e) => mapApiDealEvent(e));
|
||||
commentDraft.value = res.deal.comment ?? '';
|
||||
} catch {
|
||||
eventsFetchError.value = true;
|
||||
events.value = [];
|
||||
commentDraft.value = '';
|
||||
} finally {
|
||||
eventsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveComment() {
|
||||
if (!props.deal || !props.tenantId) return;
|
||||
commentSaving.value = true;
|
||||
commentSaveError.value = false;
|
||||
try {
|
||||
await dealsApi.updateDeal(props.deal.id, {
|
||||
tenant_id: props.tenantId,
|
||||
comment: commentDraft.value || null,
|
||||
});
|
||||
commentToastText.value = 'Комментарий сохранён.';
|
||||
commentToastOpen.value = true;
|
||||
// Reload events чтобы показать новый deal.commented в timeline.
|
||||
await loadEvents();
|
||||
} catch {
|
||||
commentSaveError.value = true;
|
||||
commentToastText.value = 'Не удалось сохранить — попробуйте позже.';
|
||||
commentToastOpen.value = true;
|
||||
} finally {
|
||||
commentSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch при открытии drawer'а или смене сделки.
|
||||
watch(
|
||||
() => [props.open, props.deal?.id, props.tenantId] as const,
|
||||
([open]) => {
|
||||
if (open) {
|
||||
loadEvents();
|
||||
void loadReminders();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
events,
|
||||
eventsLoading,
|
||||
eventsFetchError,
|
||||
loadEvents,
|
||||
commentDraft,
|
||||
commentSaving,
|
||||
commentSaveError,
|
||||
commentToastOpen,
|
||||
commentToastText,
|
||||
saveComment,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-navigation-drawer v-model="drawerOpen" location="right" temporary :width="480" class="deal-drawer">
|
||||
<div v-if="deal" class="drawer-content">
|
||||
<DealDetailHero :deal="deal" :status="status" @close="drawerOpen = false" />
|
||||
|
||||
<v-divider />
|
||||
|
||||
<section class="section pa-5">
|
||||
<h3 class="section-title text-subtitle-2 mb-3">Параметры</h3>
|
||||
<dl class="params">
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Проект</dt>
|
||||
<dd class="text-body-2">{{ deal.project }}</dd>
|
||||
</div>
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Стоимость лида</dt>
|
||||
<dd class="text-body-2 num">{{ formatCost(deal.cost) }}</dd>
|
||||
</div>
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Менеджер</dt>
|
||||
<dd class="text-body-2">
|
||||
<v-avatar size="20" color="secondary" class="mr-1">
|
||||
<span class="text-caption">{{ deal.manager.initials }}</span>
|
||||
</v-avatar>
|
||||
{{ deal.manager.name }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Источник</dt>
|
||||
<dd class="text-body-2 link">Я.Директ → landing-1</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<section v-if="tenantId" class="section pa-5" data-testid="comment-section">
|
||||
<h3 class="section-title text-subtitle-2 mb-3">Комментарий</h3>
|
||||
<v-textarea
|
||||
v-model="commentDraft"
|
||||
placeholder="Заметка менеджера…"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
auto-grow
|
||||
rows="3"
|
||||
hide-details
|
||||
counter="5000"
|
||||
data-testid="comment-textarea"
|
||||
/>
|
||||
<div class="d-flex ga-2 mt-2 justify-end">
|
||||
<v-btn
|
||||
:loading="commentSaving"
|
||||
color="primary"
|
||||
size="small"
|
||||
prepend-icon="mdi-content-save-outline"
|
||||
data-testid="save-comment-btn"
|
||||
@click="saveComment"
|
||||
>
|
||||
Сохранить
|
||||
</v-btn>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<v-divider v-if="tenantId" />
|
||||
|
||||
<section v-if="tenantId && deal" class="section pa-5" data-testid="reminders-section">
|
||||
<div class="d-flex justify-space-between align-center mb-3">
|
||||
<h3 class="section-title text-subtitle-2 mb-0">Напоминания</h3>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
prepend-icon="mdi-plus"
|
||||
data-testid="add-reminder-btn"
|
||||
@click="reminderDialogOpen = true"
|
||||
>
|
||||
Создать
|
||||
</v-btn>
|
||||
</div>
|
||||
<div v-if="reminders.length === 0 && !remindersLoading" class="text-caption text-medium-emphasis">
|
||||
Нет активных напоминаний.
|
||||
</div>
|
||||
<ul v-else class="reminders-list">
|
||||
<li v-for="r in reminders" :key="r.id" class="reminder-row" data-testid="drawer-reminder-item">
|
||||
<v-btn
|
||||
icon="mdi-check-circle-outline"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
density="comfortable"
|
||||
:data-testid="`drawer-complete-${r.id}`"
|
||||
@click="completeReminderInDrawer(r.id)"
|
||||
/>
|
||||
<div class="reminder-body">
|
||||
<div class="reminder-text">{{ r.text || 'Без описания' }}</div>
|
||||
<div class="reminder-meta text-caption text-medium-emphasis">
|
||||
{{ formatReminderTime(r.remind_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<v-divider v-if="tenantId && deal" />
|
||||
|
||||
<DealDetailTimeline :events="events" :events-fetch-error="eventsFetchError" />
|
||||
|
||||
<v-snackbar
|
||||
v-model="commentToastOpen"
|
||||
:timeout="3000"
|
||||
:color="commentSaveError ? 'warning' : undefined"
|
||||
data-testid="comment-toast"
|
||||
location="bottom right"
|
||||
>
|
||||
{{ commentToastText }}
|
||||
</v-snackbar>
|
||||
|
||||
<ReminderDialog
|
||||
v-if="tenantId && deal"
|
||||
v-model="reminderDialogOpen"
|
||||
:deal-id="deal.id"
|
||||
@saved="onReminderSaved"
|
||||
/>
|
||||
</div>
|
||||
<aside v-if="inline" v-show="open" class="deal-detail-inline" data-testid="deal-detail-panel">
|
||||
<DealDetailBody :deal="deal" :tenant-id="tenantId" @close="close" />
|
||||
</aside>
|
||||
<v-navigation-drawer
|
||||
v-else
|
||||
v-model="drawerOpen"
|
||||
location="right"
|
||||
temporary
|
||||
:width="480"
|
||||
class="deal-drawer"
|
||||
>
|
||||
<DealDetailBody :deal="deal" :tenant-id="tenantId" @close="close" />
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
|
||||
@@ -312,75 +51,16 @@ defineExpose({
|
||||
.deal-drawer {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
color: #081319;
|
||||
}
|
||||
|
||||
.params {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
.param dt {
|
||||
font-size: 11px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.param dd {
|
||||
margin: 0;
|
||||
color: #081319;
|
||||
}
|
||||
.param .link {
|
||||
color: #0f6e56;
|
||||
cursor: pointer;
|
||||
}
|
||||
.param .link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
|
||||
.reminders-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.reminder-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
.deal-detail-inline {
|
||||
flex: 0 0 400px;
|
||||
width: 400px;
|
||||
background: #fff;
|
||||
border: 1px solid #e8e3d6;
|
||||
border-radius: 6px;
|
||||
background: #fdfaf3;
|
||||
}
|
||||
|
||||
.reminder-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.reminder-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: #081319;
|
||||
}
|
||||
|
||||
.reminder-meta {
|
||||
margin-top: 2px;
|
||||
border-radius: 8px;
|
||||
overflow-y: auto;
|
||||
align-self: flex-start;
|
||||
max-height: calc(100vh - 160px);
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Sticky-bar bulk-actions для выбранных сделок (Sprint 3 Phase C).
|
||||
*
|
||||
* Показывается когда selectedCount > 0. В trash-mode — только кнопка
|
||||
* «Восстановить»; в обычном режиме — Сменить статус (menu со списком),
|
||||
* Экспорт, Удалить.
|
||||
*
|
||||
* Контракт: stateless presentation — родитель держит `selected`, `statusMenuOpen`,
|
||||
* `leadStatuses`, передаёт через props и слушает emit'ы.
|
||||
* Sticky-bar массовой смены статуса для выбранных сделок (редизайн 2026-05-17).
|
||||
* Только смена статуса — корзина/экспорт убраны (экспорт — панель по датам).
|
||||
*/
|
||||
import type { MockDeal } from '../../composables/mockDeals';
|
||||
import type { LeadStatus } from '../../composables/leadStatuses';
|
||||
|
||||
defineProps<{
|
||||
selectedCount: number;
|
||||
trashMode: boolean;
|
||||
statusMenuOpen: boolean;
|
||||
leadStatuses: LeadStatus[];
|
||||
}>();
|
||||
@@ -22,9 +15,6 @@ defineProps<{
|
||||
defineEmits<{
|
||||
'update:statusMenuOpen': [value: boolean];
|
||||
'apply-status': [slug: MockDeal['statusSlug']];
|
||||
'apply-export': [];
|
||||
'request-delete': [];
|
||||
'apply-restore-trash': [];
|
||||
'clear-selected': [];
|
||||
}>();
|
||||
</script>
|
||||
@@ -39,73 +29,38 @@ defineEmits<{
|
||||
data-testid="bulk-bar"
|
||||
>
|
||||
<div class="bulk-bar-inner">
|
||||
<span class="bulk-count">
|
||||
Выбрано <span class="num">{{ selectedCount }}</span>
|
||||
</span>
|
||||
<span class="bulk-count">Выбрано <span class="num">{{ selectedCount }}</span></span>
|
||||
<v-spacer />
|
||||
<!-- В trash-mode только Восстановить; в обычном режиме — полный набор. -->
|
||||
<v-btn
|
||||
v-if="trashMode"
|
||||
variant="tonal"
|
||||
color="success"
|
||||
size="small"
|
||||
prepend-icon="mdi-restore"
|
||||
data-testid="bulk-restore-trash-btn"
|
||||
@click="$emit('apply-restore-trash')"
|
||||
<v-menu
|
||||
:model-value="statusMenuOpen"
|
||||
:close-on-content-click="false"
|
||||
@update:model-value="(v: boolean) => $emit('update:statusMenuOpen', v)"
|
||||
>
|
||||
Восстановить
|
||||
</v-btn>
|
||||
<template v-if="!trashMode">
|
||||
<v-menu
|
||||
:model-value="statusMenuOpen"
|
||||
:close-on-content-click="false"
|
||||
@update:model-value="(v: boolean) => $emit('update:statusMenuOpen', v)"
|
||||
>
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
prepend-icon="mdi-tag-arrow-right"
|
||||
data-testid="bulk-status-btn"
|
||||
>
|
||||
Сменить статус
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact" max-height="320" min-width="240">
|
||||
<v-list-item
|
||||
v-for="s in leadStatuses"
|
||||
:key="s.slug"
|
||||
:data-testid="`bulk-status-item-${s.slug}`"
|
||||
@click="$emit('apply-status', s.slug)"
|
||||
>
|
||||
<template #prepend>
|
||||
<span class="status-dot" :style="{ background: s.colorHex }" />
|
||||
</template>
|
||||
<v-list-item-title>{{ s.nameRu }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-btn
|
||||
variant="tonal"
|
||||
size="small"
|
||||
prepend-icon="mdi-download"
|
||||
data-testid="bulk-export-btn"
|
||||
@click="$emit('apply-export')"
|
||||
>
|
||||
Экспорт
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="tonal"
|
||||
color="error"
|
||||
size="small"
|
||||
prepend-icon="mdi-trash-can-outline"
|
||||
data-testid="bulk-delete-btn"
|
||||
@click="$emit('request-delete')"
|
||||
>
|
||||
Удалить
|
||||
</v-btn>
|
||||
</template>
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
prepend-icon="mdi-tag-arrow-right"
|
||||
data-testid="bulk-status-btn"
|
||||
>
|
||||
Сменить статус
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact" max-height="320" min-width="240">
|
||||
<v-list-item
|
||||
v-for="s in leadStatuses"
|
||||
:key="s.slug"
|
||||
:data-testid="`bulk-status-item-${s.slug}`"
|
||||
@click="$emit('apply-status', s.slug)"
|
||||
>
|
||||
<template #prepend>
|
||||
<span class="status-dot" :style="{ background: s.colorHex }" />
|
||||
</template>
|
||||
<v-list-item-title>{{ s.nameRu }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
variant="text"
|
||||
@@ -123,7 +78,6 @@ defineEmits<{
|
||||
font-feature-settings: 'tnum';
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
@@ -131,7 +85,6 @@ defineEmits<{
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.bulk-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
@@ -1,123 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Filter-bar для DealsView (Sprint 3 Phase C):
|
||||
* - btn-toggle с DEALS_TABS (active/all/...) + chip-counts
|
||||
* - search input (имя/телефон/проект)
|
||||
* - multi-select Проект и Менеджер
|
||||
* - кнопка «Сбросить фильтры» (если хоть один из multi-select заполнен)
|
||||
*
|
||||
* Состояние держится в родителе через v-model:* (двунаправленные связки).
|
||||
* Фильтр-бар реестра «Сделки»: поиск по телефону + 3 select'а (Статус, Проект,
|
||||
* Город). Состояние держит родитель через v-model:*. Город — пока без данных
|
||||
* (источник §4 спеки не определён): select disabled при пустом availableCities.
|
||||
*/
|
||||
import { DEALS_TABS } from '../../composables/mockDeals';
|
||||
import type { LeadStatus } from '../../composables/leadStatuses';
|
||||
|
||||
defineProps<{
|
||||
activeTab: (typeof DEALS_TABS)[number]['id'];
|
||||
searchQuery: string;
|
||||
filterProjects: string[];
|
||||
filterManagers: string[];
|
||||
availableProjects: string[];
|
||||
availableManagers: { name: string; initials: string }[];
|
||||
counts: Record<string, number>;
|
||||
const props = defineProps<{
|
||||
searchPhone: string;
|
||||
filterStatus: string | null;
|
||||
filterProject: number | null;
|
||||
filterCity: string | null;
|
||||
leadStatuses: LeadStatus[];
|
||||
availableProjects: { id: number; name: string }[];
|
||||
availableCities: string[];
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
'update:activeTab': [value: (typeof DEALS_TABS)[number]['id']];
|
||||
'update:searchQuery': [value: string];
|
||||
'update:filterProjects': [value: string[]];
|
||||
'update:filterManagers': [value: string[]];
|
||||
'update:searchPhone': [value: string];
|
||||
'update:filterStatus': [value: string | null];
|
||||
'update:filterProject': [value: number | null];
|
||||
'update:filterCity': [value: string | null];
|
||||
'clear-filters': [];
|
||||
}>();
|
||||
|
||||
const hasActiveFilter = () =>
|
||||
props.filterStatus !== null || props.filterProject !== null || props.filterCity !== null;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter-bar mt-4">
|
||||
<v-btn-toggle
|
||||
:model-value="activeTab"
|
||||
mandatory
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
@update:model-value="(v: (typeof DEALS_TABS)[number]['id']) => $emit('update:activeTab', v)"
|
||||
>
|
||||
<v-btn v-for="tab in DEALS_TABS" :key="tab.id" :value="tab.id" size="small">
|
||||
{{ tab.label }}
|
||||
<v-chip size="x-small" class="ml-2 chip-count" variant="tonal">
|
||||
{{ counts[tab.id] }}
|
||||
</v-chip>
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
|
||||
<div class="deals-filters">
|
||||
<v-text-field
|
||||
:model-value="searchQuery"
|
||||
placeholder="Поиск: имя, телефон, проект…"
|
||||
:model-value="searchPhone"
|
||||
placeholder="Поиск по телефону…"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
class="search-input ml-4"
|
||||
@update:model-value="(v: string) => $emit('update:searchQuery', v ?? '')"
|
||||
class="filters-search"
|
||||
data-testid="filter-search-phone"
|
||||
@update:model-value="(v: string) => $emit('update:searchPhone', v ?? '')"
|
||||
/>
|
||||
|
||||
<v-select
|
||||
:model-value="filterProjects"
|
||||
:model-value="filterStatus"
|
||||
:items="leadStatuses"
|
||||
item-title="nameRu"
|
||||
item-value="slug"
|
||||
label="Статус"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
class="filters-select"
|
||||
data-testid="filter-status"
|
||||
@update:model-value="(v: string | null) => $emit('update:filterStatus', v ?? null)"
|
||||
/>
|
||||
<v-select
|
||||
:model-value="filterProject"
|
||||
:items="availableProjects"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
clearable
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
label="Проект"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
label="Проект"
|
||||
style="min-width: 180px; max-width: 260px"
|
||||
data-testid="filter-projects"
|
||||
@update:model-value="(v: string[]) => $emit('update:filterProjects', v ?? [])"
|
||||
clearable
|
||||
class="filters-select"
|
||||
data-testid="filter-project"
|
||||
@update:model-value="(v: number | null) => $emit('update:filterProject', v ?? null)"
|
||||
/>
|
||||
<v-select
|
||||
:model-value="filterManagers"
|
||||
:items="availableManagers"
|
||||
item-title="name"
|
||||
item-value="name"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
clearable
|
||||
:model-value="filterCity"
|
||||
:items="availableCities"
|
||||
label="Город"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
label="Менеджер"
|
||||
style="min-width: 180px; max-width: 260px"
|
||||
data-testid="filter-managers"
|
||||
@update:model-value="(v: string[]) => $emit('update:filterManagers', v ?? [])"
|
||||
clearable
|
||||
:disabled="availableCities.length === 0"
|
||||
class="filters-select"
|
||||
data-testid="filter-city"
|
||||
@update:model-value="(v: string | null) => $emit('update:filterCity', v ?? null)"
|
||||
/>
|
||||
<v-btn
|
||||
v-if="filterProjects.length > 0 || filterManagers.length > 0"
|
||||
v-if="hasActiveFilter()"
|
||||
variant="text"
|
||||
size="small"
|
||||
prepend-icon="mdi-filter-off"
|
||||
data-testid="clear-filters-btn"
|
||||
@click="$emit('clear-filters')"
|
||||
>
|
||||
Сбросить фильтры
|
||||
Сбросить
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-bar {
|
||||
.deals-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1 1 320px;
|
||||
max-width: 360px;
|
||||
.filters-search {
|
||||
flex: 1 1 240px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.chip-count {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
.filters-select {
|
||||
min-width: 170px;
|
||||
max-width: 220px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,32 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Таблица сделок (Sprint 3 Phase C — extraction из DealsView).
|
||||
*
|
||||
* Логически замкнутый блок: v-data-table со всеми типизированными слотами
|
||||
* (Vuetify 3.12 VDataTableSlots, Sprint 2 Phase B / O-stack-05).
|
||||
*
|
||||
* Контракт:
|
||||
* props:
|
||||
* - deals: MockDeal[] — отфильтрованный список (computed в родителе).
|
||||
* - selectedIds: number[] — v-model:selected (двунаправленно).
|
||||
* - statusBySlug: Map<string, LeadStatus> — для status-chip color/label.
|
||||
* emits:
|
||||
* - update:selectedIds — sync v-model selected с родителем.
|
||||
* - row-click(deal) — раскрыть drawer.
|
||||
* Таблица реестра лидов «Сделки» (редизайн 2026-05-17).
|
||||
* Колонки: чекбокс · Телефон · Источник · Город · Статус · Напоминание ·
|
||||
* Комментарий · Поставлен. Напоминание/Комментарий — read-only.
|
||||
*/
|
||||
import type { MockDeal } from '../../composables/mockDeals';
|
||||
import type { LeadStatus } from '../../composables/leadStatuses';
|
||||
import StatusPill from '../ui/StatusPill.vue';
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
deals: MockDeal[];
|
||||
selectedIds: number[];
|
||||
statusBySlug: Map<string, LeadStatus>;
|
||||
// Task 15: row height from density toggle (44 comfortable / 36 compact).
|
||||
rowHeight?: number;
|
||||
activeDealId?: number | null;
|
||||
}>(),
|
||||
{ rowHeight: 44 },
|
||||
{ activeDealId: null },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -34,18 +23,22 @@ const emit = defineEmits<{
|
||||
'row-click': [deal: MockDeal];
|
||||
}>();
|
||||
|
||||
function onSelectedUpdate(value: number[]) {
|
||||
emit('update:selectedIds', value);
|
||||
const SIGNAL_LABELS: Record<string, string> = { call: 'Звонки', site: 'Сайт', sms: 'СМС' };
|
||||
|
||||
function signalLabel(t: MockDeal['signalType']): string {
|
||||
return t ? (SIGNAL_LABELS[t] ?? '') : '';
|
||||
}
|
||||
|
||||
function formatRelative(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes} мин назад`;
|
||||
if (minutes < 60 * 24) return `${Math.floor(minutes / 60)} ч назад`;
|
||||
return `${Math.floor(minutes / (60 * 24))} д назад`;
|
||||
function formatDateTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
function formatCost(cost: number): string {
|
||||
return new Intl.NumberFormat('ru-RU').format(cost) + ' ₽';
|
||||
function rowProps(deal: MockDeal): Record<string, unknown> {
|
||||
return { class: deal.id === props.activeDealId ? 'deals-row-active' : '' };
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -55,72 +48,61 @@ function formatCost(cost: number): string {
|
||||
:model-value="selectedIds"
|
||||
:items="deals"
|
||||
:headers="[
|
||||
{ title: 'Лид', key: 'name', sortable: true },
|
||||
{ title: 'Телефон', key: 'phone', sortable: true },
|
||||
{ title: 'Источник', key: 'project', sortable: false },
|
||||
{ title: 'Город', key: 'city', sortable: false },
|
||||
{ title: 'Статус', key: 'statusSlug', sortable: false },
|
||||
{ title: 'Проект', key: 'project', sortable: false },
|
||||
{ title: 'Менеджер', key: 'manager', sortable: false },
|
||||
{ title: 'Стоимость', key: 'cost', align: 'end', sortable: true },
|
||||
{ title: 'Время', key: 'receivedMinutesAgo', align: 'end', sortable: true },
|
||||
{ title: 'Напоминание', key: 'nextReminderAt', sortable: true },
|
||||
{ title: 'Комментарий', key: 'comment', sortable: false },
|
||||
{ title: 'Поставлен', key: 'receivedAt', align: 'end', sortable: true },
|
||||
]"
|
||||
show-select
|
||||
item-value="id"
|
||||
items-per-page="-1"
|
||||
hide-default-footer
|
||||
hover
|
||||
:density="rowHeight && rowHeight < 40 ? 'compact' : 'comfortable'"
|
||||
:row-props="() => ({ class: 'ld-hover-lift ld-stagger-row', style: { height: rowHeight + 'px' } })"
|
||||
@update:model-value="onSelectedUpdate"
|
||||
:row-props="(p: { item: MockDeal }) => rowProps(p.item)"
|
||||
@update:model-value="(v: number[]) => emit('update:selectedIds', v)"
|
||||
@click:row="(_e: Event, { item }: { item: MockDeal }) => emit('row-click', item)"
|
||||
>
|
||||
<!--
|
||||
Vuetify 3.12 типизированные слоты VDataTable (Sprint 2 Phase B / O-stack-05).
|
||||
`:items="deals"` (MockDeal[]) → Vuetify через VDataTableSlots<ItemType<T>>
|
||||
выводит `item` как `MockDeal` автоматически. Дополнительная inline-аннотация
|
||||
`{ item }: { item: MockDeal }` фиксирует этот контракт явно — IDE и vue-tsc
|
||||
проверяют доступ к полям статически.
|
||||
-->
|
||||
<template #[`item.name`]="{ item }: { item: MockDeal }">
|
||||
<div class="cell-deal">
|
||||
<v-avatar size="32" color="primary" class="mr-3">
|
||||
<span class="text-caption font-weight-medium">{{
|
||||
item.name
|
||||
.split(' ')
|
||||
.map((p: string) => p[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
}}</span>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="deal-name">{{ item.name }}</div>
|
||||
<div class="deal-phone text-caption text-medium-emphasis ld-mono-s">{{ item.phone }}</div>
|
||||
</div>
|
||||
<template #[`item.phone`]="{ item }: { item: MockDeal }">
|
||||
<span class="num ld-mono">{{ item.phone }}</span>
|
||||
</template>
|
||||
|
||||
<template #[`item.project`]="{ item }: { item: MockDeal }">
|
||||
<div class="cell-source">
|
||||
<span class="source-project">{{ item.project }}</span>
|
||||
<span v-if="signalLabel(item.signalType)" class="source-signal">{{
|
||||
signalLabel(item.signalType)
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #[`item.city`]="{ item }: { item: MockDeal }">
|
||||
<span :class="{ 'text-medium-emphasis': !item.city }">{{ item.city || '—' }}</span>
|
||||
</template>
|
||||
|
||||
<template #[`item.statusSlug`]="{ item }: { item: MockDeal }">
|
||||
<!-- Task 15: StatusPill заменяет v-chip + ручной dot. Label fallback на slug
|
||||
если nameRu отсутствует (leadStatuses store ещё не загружен). -->
|
||||
<StatusPill
|
||||
:slug="item.statusSlug"
|
||||
:label="statusBySlug.get(item.statusSlug)?.nameRu ?? item.statusSlug"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #[`item.manager`]="{ item }: { item: MockDeal }">
|
||||
<div class="cell-manager">
|
||||
<v-avatar size="22" color="secondary" class="mr-2">
|
||||
<span class="text-caption">{{ item.manager.initials }}</span>
|
||||
</v-avatar>
|
||||
{{ item.manager.name }}
|
||||
</div>
|
||||
<template #[`item.nextReminderAt`]="{ item }: { item: MockDeal }">
|
||||
<span class="num ld-mono-s" :class="{ 'text-medium-emphasis': !item.nextReminderAt }">{{
|
||||
formatDateTime(item.nextReminderAt)
|
||||
}}</span>
|
||||
</template>
|
||||
|
||||
<template #[`item.cost`]="{ item }: { item: MockDeal }">
|
||||
<span class="num ld-mono">{{ formatCost(item.cost) }}</span>
|
||||
<template #[`item.comment`]="{ item }: { item: MockDeal }">
|
||||
<span class="cell-comment" :class="{ 'text-medium-emphasis': !item.comment }">{{
|
||||
item.comment || '—'
|
||||
}}</span>
|
||||
</template>
|
||||
|
||||
<template #[`item.receivedMinutesAgo`]="{ item }: { item: MockDeal }">
|
||||
<span class="num ld-mono-s text-medium-emphasis">{{ formatRelative(item.receivedMinutesAgo) }}</span>
|
||||
<template #[`item.receivedAt`]="{ item }: { item: MockDeal }">
|
||||
<span class="num ld-mono-s">{{ formatDateTime(item.receivedAt) }}</span>
|
||||
</template>
|
||||
|
||||
<template #[`header.data-table-select`]="{ allSelected, selectAll, someSelected }">
|
||||
@@ -135,8 +117,8 @@ function formatCost(cost: number): string {
|
||||
<template #[`item.data-table-select`]="{ isSelected, toggleSelect, internalItem, item }">
|
||||
<v-checkbox-btn
|
||||
:model-value="isSelected(internalItem)"
|
||||
:aria-label="`Выбрать сделку «${(item as MockDeal).name}»`"
|
||||
@update:model-value="(v: boolean | null) => toggleSelect(internalItem)"
|
||||
:aria-label="`Выбрать сделку «${(item as MockDeal).phone}»`"
|
||||
@update:model-value="() => toggleSelect(internalItem)"
|
||||
/>
|
||||
</template>
|
||||
</v-data-table>
|
||||
@@ -151,34 +133,32 @@ function formatCost(cost: number): string {
|
||||
.deals-table-card {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cell-deal {
|
||||
.cell-source {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
flex-direction: column;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.deal-name {
|
||||
.source-project {
|
||||
font-weight: 500;
|
||||
color: #081319;
|
||||
}
|
||||
|
||||
.cell-manager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.source-signal {
|
||||
font-size: 11px;
|
||||
color: #6b6356;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
.cell-comment {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
:deep(.deals-row-active) {
|
||||
background: rgba(15, 110, 86, 0.07);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Wizard маппинга неизвестных статусов воронки из CSV-импорта (ТЗ §6.4/§6.6).
|
||||
*
|
||||
* Для каждого незамапленного русского статуса пользователь выбирает один из
|
||||
* 14 канонических slug'ов. Сохранение → POST /api/imports/unknown-statuses/resolve.
|
||||
* 5 slug'ов воронки. Сохранение → POST /api/imports/unknown-statuses/resolve.
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { resolveUnknownStatuses, type StatusMapping, type UnknownStatus } from '../../api/imports';
|
||||
@@ -18,22 +18,13 @@ const emit = defineEmits<{
|
||||
resolved: [];
|
||||
}>();
|
||||
|
||||
/** 14 канонических статусов воронки (ТЗ §6.4). */
|
||||
/** 5 статусов воронки (редизайн 2026-05-17). */
|
||||
const STATUS_OPTIONS: { value: string; title: string }[] = [
|
||||
{ value: 'new', title: 'Новые' },
|
||||
{ value: 'new', title: 'Новая сделка' },
|
||||
{ value: 'viewed', title: 'Просмотрено' },
|
||||
{ value: 'worked', title: 'Проработан' },
|
||||
{ value: 'base', title: 'База' },
|
||||
{ value: 'missed', title: 'Недозвон' },
|
||||
{ value: 'negotiations', title: 'Переговоры' },
|
||||
{ value: 'waiting_payment', title: 'Ожидаем оплаты' },
|
||||
{ value: 'partnership', title: 'Партнерка' },
|
||||
{ value: 'paid', title: 'Оплачено' },
|
||||
{ value: 'closed', title: 'Закрыто и не реализовано' },
|
||||
{ value: 'test_drive', title: 'Тест драйв' },
|
||||
{ value: 'hot', title: 'Горячий' },
|
||||
{ value: 'replacement', title: 'На замену' },
|
||||
{ value: 'final_missed', title: 'Конечный недозвон' },
|
||||
{ value: 'in_progress', title: 'В работе' },
|
||||
{ value: 'won', title: 'Сделка' },
|
||||
{ value: 'lost', title: 'Не реализовано' },
|
||||
];
|
||||
|
||||
const selection = reactive<Record<string, string | null>>({});
|
||||
|
||||
@@ -4,9 +4,9 @@ import { LEAD_STATUSES } from '../../composables/leadStatuses';
|
||||
import { MOCK_DEALS } from '../../composables/mockDeals';
|
||||
|
||||
const newStatus = LEAD_STATUSES.find((s) => s.slug === 'new')!;
|
||||
const paidStatus = LEAD_STATUSES.find((s) => s.slug === 'paid')!;
|
||||
const wonStatus = LEAD_STATUSES.find((s) => s.slug === 'won')!;
|
||||
const newDeals = MOCK_DEALS.filter((d) => d.statusSlug === 'new');
|
||||
const paidDeals = MOCK_DEALS.filter((d) => d.statusSlug === 'paid');
|
||||
const wonDeals = MOCK_DEALS.filter((d) => d.statusSlug === 'won');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -19,10 +19,10 @@ const paidDeals = MOCK_DEALS.filter((d) => d.statusSlug === 'paid');
|
||||
</v-app>
|
||||
</Variant>
|
||||
|
||||
<Variant title="«Оплачено» (2 сделки)">
|
||||
<Variant title="«Сделка» (2 сделки)">
|
||||
<v-app>
|
||||
<v-main class="story-pane">
|
||||
<KanbanColumn :status="paidStatus" :deals="paidDeals" />
|
||||
<KanbanColumn :status="wonStatus" :deals="wonDeals" />
|
||||
</v-main>
|
||||
</v-app>
|
||||
</Variant>
|
||||
|
||||
@@ -53,14 +53,12 @@ const navGroups = computed<NavGroup[]>(() => [
|
||||
},
|
||||
{ title: 'Канбан', icon: 'mdi-view-column-outline', to: '/kanban' },
|
||||
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
|
||||
{ title: 'Импорт данных', icon: 'mdi-database-import-outline', to: '/import' },
|
||||
],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Финансы',
|
||||
items: [
|
||||
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/billing' },
|
||||
{ title: 'Отчёты', icon: 'mdi-chart-box-outline', to: '/reports' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,7 +3,8 @@ import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import axios from 'axios';
|
||||
import type { Project } from '../../stores/projectsStore';
|
||||
import { useProjectsStore } from '../../stores/projectsStore';
|
||||
import { REGIONS } from '../../constants/regions';
|
||||
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
|
||||
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
|
||||
|
||||
const props = defineProps<{ project: Project | null }>();
|
||||
const emit = defineEmits<{ close: []; saved: [] }>();
|
||||
@@ -11,8 +12,7 @@ const emit = defineEmits<{ close: []; saved: [] }>();
|
||||
interface FormState {
|
||||
name: string;
|
||||
daily_limit_target: number;
|
||||
region_mask: number;
|
||||
region_mode: 'include' | 'exclude';
|
||||
regions: number[];
|
||||
delivery_days_mask: number;
|
||||
sms_senders: string[];
|
||||
sms_keyword: string;
|
||||
@@ -21,48 +21,31 @@ interface FormState {
|
||||
const form = reactive<FormState>({
|
||||
name: '',
|
||||
daily_limit_target: 50,
|
||||
region_mask: 0,
|
||||
region_mode: 'include',
|
||||
regions: [],
|
||||
delivery_days_mask: 127,
|
||||
sms_senders: [],
|
||||
sms_keyword: '',
|
||||
});
|
||||
|
||||
const selectedRegions = ref<number[]>([]);
|
||||
const selectableRegions = REGIONS.filter((r) => r.code !== 0);
|
||||
|
||||
function maskToCodes(mask: number): number[] {
|
||||
const codes: number[] = [];
|
||||
for (let i = 1; i <= 31; i++) if (mask & (1 << i)) codes.push(i);
|
||||
return codes;
|
||||
}
|
||||
|
||||
function reseedFromProject(p: Project | null): void {
|
||||
if (!p) return;
|
||||
form.name = p.name;
|
||||
form.daily_limit_target = p.daily_limit_target;
|
||||
form.region_mask = p.region_mask ?? 0;
|
||||
form.region_mode = (p.region_mode ?? 'include') as 'include' | 'exclude';
|
||||
form.regions = Array.isArray(p.regions) ? [...p.regions] : [];
|
||||
form.delivery_days_mask = p.delivery_days_mask ?? 127;
|
||||
form.sms_senders = p.sms_senders ?? [];
|
||||
form.sms_keyword = p.sms_keyword ?? '';
|
||||
selectedRegions.value = maskToCodes(form.region_mask);
|
||||
}
|
||||
reseedFromProject(props.project);
|
||||
|
||||
watch(() => props.project?.id, () => {
|
||||
reseedFromProject(props.project);
|
||||
});
|
||||
|
||||
watch(selectedRegions, (codes) => {
|
||||
if (codes.length === 0) {
|
||||
form.region_mask = 0;
|
||||
form.region_mode = 'include';
|
||||
} else {
|
||||
form.region_mask = codes.reduce((acc, c) => (c >= 1 && c <= 31 ? acc | (1 << c) : acc), 0);
|
||||
form.region_mode = 'exclude';
|
||||
}
|
||||
});
|
||||
watch(
|
||||
() => props.project?.id,
|
||||
() => {
|
||||
reseedFromProject(props.project);
|
||||
},
|
||||
);
|
||||
|
||||
const saving = ref(false);
|
||||
const errors = reactive<Record<string, string[]>>({});
|
||||
@@ -76,7 +59,9 @@ async function onPause(): Promise<void> {
|
||||
|
||||
async function onDelete(): Promise<void> {
|
||||
if (!props.project) return;
|
||||
const ok = window.confirm('Архивировать проект? Действие необратимо в Plan 5 (восстановление потребует ручного запроса).');
|
||||
const ok = window.confirm(
|
||||
'Архивировать проект? Действие необратимо в Plan 5 (восстановление потребует ручного запроса).',
|
||||
);
|
||||
if (!ok) return;
|
||||
await store.archive(props.project.id);
|
||||
emit('close');
|
||||
@@ -90,8 +75,7 @@ async function onSave(): Promise<void> {
|
||||
const payload: Record<string, unknown> = {
|
||||
name: form.name,
|
||||
daily_limit_target: form.daily_limit_target,
|
||||
region_mask: form.region_mask,
|
||||
region_mode: form.region_mode,
|
||||
regions: form.regions,
|
||||
delivery_days_mask: form.delivery_days_mask,
|
||||
};
|
||||
if (props.project.signal_type === 'sms') {
|
||||
@@ -122,7 +106,7 @@ const activeDays = computed<boolean[]>(() => {
|
||||
});
|
||||
|
||||
function toggleDay(i: number): void {
|
||||
form.delivery_days_mask ^= (1 << i);
|
||||
form.delivery_days_mask ^= 1 << i;
|
||||
}
|
||||
|
||||
const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
@@ -159,7 +143,7 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
<div class="pdd-field">
|
||||
<span class="pdd-label">Регионы (пусто = вся РФ)</span>
|
||||
<v-autocomplete
|
||||
v-model="selectedRegions"
|
||||
v-model="form.regions"
|
||||
:items="selectableRegions"
|
||||
item-title="name"
|
||||
item-value="code"
|
||||
@@ -169,7 +153,16 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
density="comfortable"
|
||||
hide-details
|
||||
data-testid="pdd-regions"
|
||||
/>
|
||||
@update:menu="repositionMenuAfterOpen"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<v-list-item v-bind="itemProps">
|
||||
<template #subtitle>
|
||||
{{ FEDERAL_DISTRICT_NAMES[item.raw.federalDistrict] || '' }}
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
</div>
|
||||
|
||||
<div class="pdd-field">
|
||||
@@ -197,13 +190,12 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
<button class="pdd-btn pdd-btn-error" data-testid="pdd-delete" @click="onDelete">🗄 Удалить</button>
|
||||
</div>
|
||||
<div class="pdd-foot-right">
|
||||
<button class="pdd-btn pdd-btn-text" data-testid="pdd-cancel" @click="$emit('close')">Отмена</button>
|
||||
<button
|
||||
class="pdd-btn pdd-btn-primary"
|
||||
data-testid="pdd-save"
|
||||
:disabled="saving"
|
||||
@click="onSave"
|
||||
>Сохранить</button>
|
||||
<button class="pdd-btn pdd-btn-text" data-testid="pdd-cancel" @click="$emit('close')">
|
||||
Отмена
|
||||
</button>
|
||||
<button class="pdd-btn pdd-btn-primary" data-testid="pdd-save" :disabled="saving" @click="onSave">
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -212,34 +204,123 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
|
||||
<style scoped>
|
||||
.project-details-drawer {
|
||||
position: fixed; top: 0; right: 0; bottom: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 480px;
|
||||
background: var(--liderra-surface, #ffffff);
|
||||
border-left: 1px solid var(--liderra-line, #e6e2d6);
|
||||
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.06);
|
||||
transform: translateX(100%);
|
||||
transition: transform 240ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
display: flex; flex-direction: column;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 5;
|
||||
}
|
||||
.project-details-drawer.open { transform: translateX(0); }
|
||||
.pdd-content { display: flex; flex-direction: column; height: 100%; }
|
||||
.pdd-head { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--liderra-line, #e6e2d6); }
|
||||
.pdd-title { font-weight: 600; font-size: 16px; }
|
||||
.pdd-close { background: none; border: 0; cursor: pointer; font-size: 18px; padding: 4px; }
|
||||
.pdd-body { padding: 16px 20px; display: flex; flex-direction: column; gap: 14px; flex: 1; overflow-y: auto; }
|
||||
.pdd-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.pdd-label { font-size: 12px; color: #6b6f72; }
|
||||
.pdd-input { padding: 8px 10px; border: 1px solid var(--liderra-line, #e6e2d6); border-radius: 6px; font: inherit; }
|
||||
.pdd-days { display: flex; gap: 4px; }
|
||||
.pdd-day { padding: 6px 10px; border: 1px solid var(--liderra-line, #e6e2d6); background: #ffffff; border-radius: 4px; cursor: pointer; font: inherit; }
|
||||
.pdd-day.active { background: #0f6e56; color: #ffffff; border-color: #0f6e56; }
|
||||
.pdd-foot { display: flex; justify-content: space-between; padding: 12px 20px; border-top: 1px solid var(--liderra-line, #e6e2d6); }
|
||||
.pdd-foot-left, .pdd-foot-right { display: flex; gap: 8px; }
|
||||
.pdd-btn { padding: 6px 14px; border: 0; border-radius: 6px; cursor: pointer; font: inherit; }
|
||||
.pdd-btn-text { background: transparent; color: #081319; }
|
||||
.pdd-btn-primary { background: #0f6e56; color: #ffffff; }
|
||||
.pdd-btn-warning { background: #f59e0b; color: #ffffff; }
|
||||
.pdd-btn-error { background: #dc2626; color: #ffffff; }
|
||||
.pdd-error { color: #dc2626; font-size: 12px; margin-top: 4px; }
|
||||
.project-details-drawer.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.pdd-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.pdd-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--liderra-line, #e6e2d6);
|
||||
}
|
||||
.pdd-title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
.pdd-close {
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
padding: 4px;
|
||||
}
|
||||
.pdd-body {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.pdd-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.pdd-label {
|
||||
font-size: 12px;
|
||||
color: #6b6f72;
|
||||
}
|
||||
.pdd-input {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--liderra-line, #e6e2d6);
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
}
|
||||
.pdd-days {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.pdd-day {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--liderra-line, #e6e2d6);
|
||||
background: #ffffff;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.pdd-day.active {
|
||||
background: #0f6e56;
|
||||
color: #ffffff;
|
||||
border-color: #0f6e56;
|
||||
}
|
||||
.pdd-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--liderra-line, #e6e2d6);
|
||||
}
|
||||
.pdd-foot-left,
|
||||
.pdd-foot-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.pdd-btn {
|
||||
padding: 6px 14px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.pdd-btn-text {
|
||||
background: transparent;
|
||||
color: #081319;
|
||||
}
|
||||
.pdd-btn-primary {
|
||||
background: #0f6e56;
|
||||
color: #ffffff;
|
||||
}
|
||||
.pdd-btn-warning {
|
||||
background: #f59e0b;
|
||||
color: #ffffff;
|
||||
}
|
||||
.pdd-btn-error {
|
||||
background: #dc2626;
|
||||
color: #ffffff;
|
||||
}
|
||||
.pdd-error {
|
||||
color: #dc2626;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,41 +3,69 @@
|
||||
<v-card>
|
||||
<v-card-title>Регионы — для {{ count }} проектов</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="mb-4">
|
||||
<div class="text-caption text-success font-weight-medium mb-2">➕ Добавить</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<v-chip
|
||||
v-for="r in FEDERAL_DISTRICTS"
|
||||
:key="`add-${r.bit}`"
|
||||
:data-testid="`region-add-${r.bit}`"
|
||||
:color="addMask & r.bit ? 'success' : undefined"
|
||||
:variant="addMask & r.bit ? 'flat' : 'outlined'"
|
||||
size="small"
|
||||
@click="toggleAdd(r.bit)"
|
||||
>{{ r.label }}</v-chip
|
||||
>
|
||||
</div>
|
||||
<p class="text-caption text-medium-emphasis mb-4">
|
||||
Изменения применяются к каждому из {{ count }} выбранных проектов: выбранные субъекты
|
||||
добавляются к их регионам или убираются из них.
|
||||
</p>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="text-caption text-success font-weight-medium mb-2">➕ Добавить регионы</div>
|
||||
<v-autocomplete
|
||||
v-model="addRegions"
|
||||
:items="selectableRegions"
|
||||
item-title="name"
|
||||
item-value="code"
|
||||
label="Субъекты РФ"
|
||||
multiple
|
||||
chips
|
||||
clearable
|
||||
density="comfortable"
|
||||
data-testid="region-add-select"
|
||||
@update:menu="repositionMenuAfterOpen"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<v-list-item v-bind="itemProps">
|
||||
<template #subtitle>
|
||||
{{ FEDERAL_DISTRICT_NAMES[item.raw.federalDistrict] || '' }}
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-caption text-error font-weight-medium mb-2">➖ Убрать</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<v-chip
|
||||
v-for="r in FEDERAL_DISTRICTS"
|
||||
:key="`remove-${r.bit}`"
|
||||
:data-testid="`region-remove-${r.bit}`"
|
||||
:color="removeMask & r.bit ? 'error' : undefined"
|
||||
:variant="removeMask & r.bit ? 'flat' : 'outlined'"
|
||||
size="small"
|
||||
@click="toggleRemove(r.bit)"
|
||||
>{{ r.label }}</v-chip
|
||||
>
|
||||
</div>
|
||||
<div class="text-caption text-error font-weight-medium mb-2">➖ Убрать регионы</div>
|
||||
<v-autocomplete
|
||||
v-model="removeRegions"
|
||||
:items="selectableRegions"
|
||||
item-title="name"
|
||||
item-value="code"
|
||||
label="Субъекты РФ"
|
||||
multiple
|
||||
chips
|
||||
clearable
|
||||
density="comfortable"
|
||||
data-testid="region-remove-select"
|
||||
@update:menu="repositionMenuAfterOpen"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<v-list-item v-bind="itemProps">
|
||||
<template #subtitle>
|
||||
{{ FEDERAL_DISTRICT_NAMES[item.raw.federalDistrict] || '' }}
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn data-testid="cancel" @click="open = false">Отмена</v-btn>
|
||||
<v-btn color="primary" data-testid="apply" :disabled="addMask === 0 && removeMask === 0" @click="apply"
|
||||
<v-btn
|
||||
color="primary"
|
||||
data-testid="apply"
|
||||
:disabled="addRegions.length === 0 && removeRegions.length === 0"
|
||||
@click="apply"
|
||||
>Применить к {{ count }}</v-btn
|
||||
>
|
||||
</v-card-actions>
|
||||
@@ -47,47 +75,40 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { FEDERAL_DISTRICTS } from '../../constants/federal-districts';
|
||||
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
|
||||
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; count: number }>();
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
apply: [payload: { add: number; remove: number }];
|
||||
apply: [payload: { add_regions: number[]; remove_regions: number[] }];
|
||||
}>();
|
||||
|
||||
// code:0 — sentinel «Вся РФ»; в bulk add/remove субъектов не выбирается.
|
||||
const selectableRegions = REGIONS.filter((r) => r.code !== 0);
|
||||
|
||||
const open = ref(props.modelValue);
|
||||
const addMask = ref(0);
|
||||
const removeMask = ref(0);
|
||||
const addRegions = ref<number[]>([]);
|
||||
const removeRegions = ref<number[]>([]);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
open.value = val;
|
||||
if (val) {
|
||||
addMask.value = 0;
|
||||
removeMask.value = 0;
|
||||
addRegions.value = [];
|
||||
removeRegions.value = [];
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(open, (val) => {
|
||||
emit('update:modelValue', val);
|
||||
});
|
||||
|
||||
function toggleAdd(bit: number) {
|
||||
addMask.value ^= bit;
|
||||
if (addMask.value & bit) removeMask.value &= ~bit;
|
||||
}
|
||||
|
||||
function toggleRemove(bit: number) {
|
||||
removeMask.value ^= bit;
|
||||
if (removeMask.value & bit) addMask.value &= ~bit;
|
||||
}
|
||||
watch(open, (val) => emit('update:modelValue', val));
|
||||
|
||||
function apply() {
|
||||
emit('apply', { add: addMask.value, remove: removeMask.value });
|
||||
addMask.value = 0;
|
||||
removeMask.value = 0;
|
||||
emit('apply', { add_regions: [...addRegions.value], remove_regions: [...removeRegions.value] });
|
||||
addRegions.value = [];
|
||||
removeRegions.value = [];
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
defineExpose({ addRegions, removeRegions, apply });
|
||||
</script>
|
||||
|
||||
@@ -73,5 +73,10 @@ export function mapApiDeal(api: ApiDeal, now: Date = new Date()): MockDeal {
|
||||
},
|
||||
cost: 0,
|
||||
receivedMinutesAgo,
|
||||
signalType: (api.project_signal_type as MockDeal['signalType']) ?? null,
|
||||
city: api.city,
|
||||
comment: api.comment,
|
||||
receivedAt: api.received_at,
|
||||
nextReminderAt: api.next_reminder_at,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 14 системных и пользовательских статусов воронки.
|
||||
* 5 системных статусов воронки (редизайн 2026-05-17).
|
||||
*
|
||||
* Источник истины: db/schema.sql:2130 (lead_statuses seed). НЕ из BRANDBOOK_v2 §3.6
|
||||
* (расхождение #1 handoff vs ТЗ из реестра v1.13: handoff содержит 14 «обобщённых»
|
||||
@@ -18,18 +18,9 @@ export interface LeadStatus {
|
||||
}
|
||||
|
||||
export const LEAD_STATUSES: LeadStatus[] = [
|
||||
{ slug: 'new', nameRu: 'Новые', isSystem: true, sortOrder: 1, colorHex: '#3B82F6' },
|
||||
{ slug: 'new', nameRu: 'Новая сделка', isSystem: true, sortOrder: 1, colorHex: '#3B82F6' },
|
||||
{ slug: 'viewed', nameRu: 'Просмотрено', isSystem: true, sortOrder: 2, colorHex: '#8B5CF6' },
|
||||
{ slug: 'worked', nameRu: 'Проработан', isSystem: true, sortOrder: 3, colorHex: '#06B6D4' },
|
||||
{ slug: 'base', nameRu: 'База', isSystem: false, sortOrder: 4, colorHex: '#64748B' },
|
||||
{ slug: 'missed', nameRu: 'Недозвон', isSystem: false, sortOrder: 5, colorHex: '#F59E0B' },
|
||||
{ slug: 'negotiations', nameRu: 'Переговоры', isSystem: false, sortOrder: 6, colorHex: '#EAB308' },
|
||||
{ slug: 'waiting_payment', nameRu: 'Ожидаем оплаты', isSystem: false, sortOrder: 7, colorHex: '#A78BFA' },
|
||||
{ slug: 'partnership', nameRu: 'Партнерка', isSystem: false, sortOrder: 8, colorHex: '#EC4899' },
|
||||
{ slug: 'paid', nameRu: 'Оплачено', isSystem: true, sortOrder: 9, colorHex: '#10B981' },
|
||||
{ slug: 'closed', nameRu: 'Закрыто и не реализовано', isSystem: true, sortOrder: 10, colorHex: '#6B7280' },
|
||||
{ slug: 'test_drive', nameRu: 'Тест драйв', isSystem: false, sortOrder: 11, colorHex: '#14B8A6' },
|
||||
{ slug: 'hot', nameRu: 'Горячий', isSystem: false, sortOrder: 12, colorHex: '#EF4444' },
|
||||
{ slug: 'replacement', nameRu: 'На замену', isSystem: false, sortOrder: 13, colorHex: '#F97316' },
|
||||
{ slug: 'final_missed', nameRu: 'Конечный недозвон', isSystem: true, sortOrder: 14, colorHex: '#1F2937' },
|
||||
{ slug: 'in_progress', nameRu: 'В работе', isSystem: true, sortOrder: 3, colorHex: '#06B6D4' },
|
||||
{ slug: 'won', nameRu: 'Сделка', isSystem: true, sortOrder: 4, colorHex: '#10B981' },
|
||||
{ slug: 'lost', nameRu: 'Не реализовано', isSystem: true, sortOrder: 5, colorHex: '#6B7280' },
|
||||
];
|
||||
|
||||
@@ -16,6 +16,12 @@ export interface MockDeal {
|
||||
manager: { initials: string; name: string };
|
||||
cost: number;
|
||||
receivedMinutesAgo: number;
|
||||
// Редизайн «Сделки» (2026-05-17). Опциональны — Канбан/MOCK_DEALS не трогаем.
|
||||
signalType?: 'call' | 'site' | 'sms' | null;
|
||||
city?: string | null;
|
||||
comment?: string | null;
|
||||
receivedAt?: string | null; // ISO — колонка «Поставлен»
|
||||
nextReminderAt?: string | null; // ISO — колонка «Напоминание»
|
||||
}
|
||||
|
||||
export const MOCK_DEALS: MockDeal[] = [
|
||||
@@ -33,7 +39,7 @@ export const MOCK_DEALS: MockDeal[] = [
|
||||
id: 2,
|
||||
name: 'Дмитрий Кузнецов',
|
||||
phone: '+7 (903) 412-58-90',
|
||||
statusSlug: 'worked',
|
||||
statusSlug: 'in_progress',
|
||||
project: 'Окна Москва',
|
||||
manager: { initials: 'ОР', name: 'Ольга Р.' },
|
||||
cost: 2400,
|
||||
@@ -43,7 +49,7 @@ export const MOCK_DEALS: MockDeal[] = [
|
||||
id: 3,
|
||||
name: 'Светлана Иванова',
|
||||
phone: '+7 (925) 309-44-12',
|
||||
statusSlug: 'negotiations',
|
||||
statusSlug: 'in_progress',
|
||||
project: 'Окна Москва',
|
||||
manager: { initials: 'ИП', name: 'Иван П.' },
|
||||
cost: 2100,
|
||||
@@ -53,7 +59,7 @@ export const MOCK_DEALS: MockDeal[] = [
|
||||
id: 4,
|
||||
name: 'Марина Лебедева',
|
||||
phone: '+7 (915) 778-90-32',
|
||||
statusSlug: 'paid',
|
||||
statusSlug: 'won',
|
||||
project: 'Натяжные потолки',
|
||||
manager: { initials: 'ОР', name: 'Ольга Р.' },
|
||||
cost: 2350,
|
||||
@@ -63,7 +69,7 @@ export const MOCK_DEALS: MockDeal[] = [
|
||||
id: 5,
|
||||
name: 'Алексей Петров',
|
||||
phone: '+7 (905) 132-46-87',
|
||||
statusSlug: 'missed',
|
||||
statusSlug: 'in_progress',
|
||||
project: 'Окна Москва',
|
||||
manager: { initials: 'ИП', name: 'Иван П.' },
|
||||
cost: 2400,
|
||||
@@ -73,7 +79,7 @@ export const MOCK_DEALS: MockDeal[] = [
|
||||
id: 6,
|
||||
name: 'Екатерина Морозова',
|
||||
phone: '+7 (926) 554-21-09',
|
||||
statusSlug: 'waiting_payment',
|
||||
statusSlug: 'in_progress',
|
||||
project: 'Натяжные потолки',
|
||||
manager: { initials: 'ОР', name: 'Ольга Р.' },
|
||||
cost: 1950,
|
||||
@@ -93,7 +99,7 @@ export const MOCK_DEALS: MockDeal[] = [
|
||||
id: 8,
|
||||
name: 'Тимур Алиев',
|
||||
phone: '+7 (903) 765-09-21',
|
||||
statusSlug: 'hot',
|
||||
statusSlug: 'in_progress',
|
||||
project: 'Натяжные потолки',
|
||||
manager: { initials: 'ОР', name: 'Ольга Р.' },
|
||||
cost: 1850,
|
||||
@@ -103,7 +109,7 @@ export const MOCK_DEALS: MockDeal[] = [
|
||||
id: 9,
|
||||
name: 'Наталья Семёнова',
|
||||
phone: '+7 (910) 244-67-83',
|
||||
statusSlug: 'closed',
|
||||
statusSlug: 'lost',
|
||||
project: 'Окна Москва',
|
||||
manager: { initials: 'ИП', name: 'Иван П.' },
|
||||
cost: 2400,
|
||||
@@ -113,7 +119,7 @@ export const MOCK_DEALS: MockDeal[] = [
|
||||
id: 10,
|
||||
name: 'Олег Григорьев',
|
||||
phone: '+7 (909) 411-52-76',
|
||||
statusSlug: 'partnership',
|
||||
statusSlug: 'in_progress',
|
||||
project: 'Натяжные потолки',
|
||||
manager: { initials: 'ОР', name: 'Ольга Р.' },
|
||||
cost: 1850,
|
||||
@@ -123,7 +129,7 @@ export const MOCK_DEALS: MockDeal[] = [
|
||||
id: 11,
|
||||
name: 'Ирина Зайцева',
|
||||
phone: '+7 (916) 671-98-04',
|
||||
statusSlug: 'final_missed',
|
||||
statusSlug: 'in_progress',
|
||||
project: 'Окна Москва',
|
||||
manager: { initials: 'ИП', name: 'Иван П.' },
|
||||
cost: 2400,
|
||||
@@ -133,7 +139,7 @@ export const MOCK_DEALS: MockDeal[] = [
|
||||
id: 12,
|
||||
name: 'Сергей Никитин',
|
||||
phone: '+7 (925) 198-43-58',
|
||||
statusSlug: 'paid',
|
||||
statusSlug: 'won',
|
||||
project: 'Натяжные потолки',
|
||||
manager: { initials: 'ОР', name: 'Ольга Р.' },
|
||||
cost: 1850,
|
||||
@@ -141,24 +147,6 @@ export const MOCK_DEALS: MockDeal[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Срезы-фильтры для chiprow в DealsView. Каждый срез — массив slug'ов или
|
||||
* предикат включения. На API-стороне уйдут как ?status_in=...
|
||||
*/
|
||||
export interface DealsTab {
|
||||
id: 'all' | 'active' | 'waiting_payment' | 'closed' | 'invalid';
|
||||
label: string;
|
||||
slugs: LeadStatus['slug'][] | null; // null = все
|
||||
}
|
||||
|
||||
export const DEALS_TABS: DealsTab[] = [
|
||||
{ id: 'all', label: 'Все', slugs: null },
|
||||
{ id: 'active', label: 'Активные', slugs: ['new', 'viewed', 'worked', 'negotiations', 'hot'] },
|
||||
{ id: 'waiting_payment', label: 'Ждут оплату', slugs: ['waiting_payment'] },
|
||||
{ id: 'closed', label: 'Закрытые', slugs: ['paid', 'closed'] },
|
||||
{ id: 'invalid', label: 'Невалидные', slugs: ['missed', 'final_missed'] },
|
||||
];
|
||||
|
||||
/**
|
||||
* Доступные проекты и менеджеры для NewDealDialog. На API: GET /api/projects /
|
||||
* GET /api/managers (фильтр по tenant_id из middleware).
|
||||
|
||||
@@ -13,11 +13,13 @@ export interface PillStyle {
|
||||
|
||||
export const STATUS_PILL_SLUGS = [
|
||||
'new',
|
||||
'viewed',
|
||||
'in_progress',
|
||||
'callback',
|
||||
'quality',
|
||||
'meeting_set',
|
||||
'won',
|
||||
'lost',
|
||||
'refund',
|
||||
'duplicate',
|
||||
'junk',
|
||||
@@ -32,11 +34,13 @@ type StatusPillSlug = (typeof STATUS_PILL_SLUGS)[number];
|
||||
|
||||
const STYLES: Record<StatusPillSlug, PillStyle> = {
|
||||
new: { bg: 'rgba(15,110,86,0.12)', color: '#0F6E56' },
|
||||
viewed: { bg: 'rgba(122,91,163,0.15)', color: '#7A5BA3' },
|
||||
in_progress: { bg: 'rgba(63,124,149,0.12)', color: '#2A5A6E' },
|
||||
callback: { bg: 'rgba(217,164,65,0.18)', color: '#A07820' },
|
||||
quality: { bg: 'rgba(46,139,87,0.15)', color: '#2E8B57' },
|
||||
meeting_set: { bg: 'rgba(122,91,163,0.15)', color: '#7A5BA3' },
|
||||
won: { bg: 'rgba(46,139,87,0.22)', color: '#1F6940', fontWeight: 600 },
|
||||
lost: { bg: 'rgba(107,99,86,0.18)', color: '#6B6356' },
|
||||
refund: { bg: 'rgba(204,110,80,0.15)', color: '#B0563D' },
|
||||
duplicate: { bg: 'rgba(1,32,25,0.08)', color: '#3A3A3A' },
|
||||
junk: { bg: 'rgba(184,58,58,0.10)', color: '#B83A3A' },
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
export interface FederalDistrict {
|
||||
bit: number; // 1, 2, 4, ..., 128
|
||||
label: string;
|
||||
}
|
||||
|
||||
// 8 ФО РФ — соответствует schema `projects.region_mask BETWEEN 0 AND 255`.
|
||||
// Используется в bulk-операциях по проектам (грубое выделение).
|
||||
// Для тонкого pick'а subject-level см. constants/regions.ts.
|
||||
export const FEDERAL_DISTRICTS: FederalDistrict[] = [
|
||||
{ bit: 1, label: 'Центральный' },
|
||||
{ bit: 2, label: 'Северо-Западный' },
|
||||
{ bit: 4, label: 'Южный' },
|
||||
{ bit: 8, label: 'Северо-Кавказский' },
|
||||
{ bit: 16, label: 'Приволжский' },
|
||||
{ bit: 32, label: 'Уральский' },
|
||||
{ bit: 64, label: 'Сибирский' },
|
||||
{ bit: 128, label: 'Дальневосточный' },
|
||||
];
|
||||
@@ -1,42 +1,119 @@
|
||||
export interface Region {
|
||||
code: number;
|
||||
name: string;
|
||||
code: number; // 1..89, sequential по конституционному порядку (Art. 65)
|
||||
name: string; // официальное название субъекта
|
||||
federalDistrict: number; // 1..8 (см. FEDERAL_DISTRICT_NAMES)
|
||||
}
|
||||
|
||||
// MVP: 31 региона (коды 1..31) ограничены 32-bit region_mask из Plan 5 Task 9.
|
||||
// Sentinel code:0 = «Вся РФ» (включает все регионы, эквивалент пустой маски).
|
||||
// Имена — официальные субъекты РФ по конституционному порядку нумерации.
|
||||
// Конституционный порядок (ст. 65 Конституции РФ, ред. 2022):
|
||||
// 24 республики (1..24) → 9 краёв (25..33) → 48 областей (34..81) →
|
||||
// 3 города фед.знач. (82..84) → 1 АО Еврейская (85) → 4 АО (86..89).
|
||||
// Sentinel code:0 = "Вся РФ" (UI hint, в БД хранится как regions=[]).
|
||||
export const REGIONS: Region[] = [
|
||||
{ code: 0, name: 'Вся РФ' },
|
||||
{ code: 1, name: 'Республика Адыгея' },
|
||||
{ code: 2, name: 'Республика Башкортостан' },
|
||||
{ code: 3, name: 'Республика Бурятия' },
|
||||
{ code: 4, name: 'Республика Алтай' },
|
||||
{ code: 5, name: 'Республика Дагестан' },
|
||||
{ code: 6, name: 'Республика Ингушетия' },
|
||||
{ code: 7, name: 'Кабардино-Балкарская Республика' },
|
||||
{ code: 8, name: 'Республика Калмыкия' },
|
||||
{ code: 9, name: 'Карачаево-Черкесская Республика' },
|
||||
{ code: 10, name: 'Республика Карелия' },
|
||||
{ code: 11, name: 'Республика Коми' },
|
||||
{ code: 12, name: 'Республика Марий Эл' },
|
||||
{ code: 13, name: 'Республика Мордовия' },
|
||||
{ code: 14, name: 'Республика Саха (Якутия)' },
|
||||
{ code: 15, name: 'Республика Северная Осетия — Алания' },
|
||||
{ code: 16, name: 'Республика Татарстан' },
|
||||
{ code: 17, name: 'Республика Тыва' },
|
||||
{ code: 18, name: 'Удмуртская Республика' },
|
||||
{ code: 19, name: 'Республика Хакасия' },
|
||||
{ code: 20, name: 'Чеченская Республика' },
|
||||
{ code: 21, name: 'Чувашская Республика' },
|
||||
{ code: 22, name: 'Алтайский край' },
|
||||
{ code: 23, name: 'Краснодарский край' },
|
||||
{ code: 24, name: 'Красноярский край' },
|
||||
{ code: 25, name: 'Приморский край' },
|
||||
{ code: 26, name: 'Ставропольский край' },
|
||||
{ code: 27, name: 'Хабаровский край' },
|
||||
{ code: 28, name: 'Амурская область' },
|
||||
{ code: 29, name: 'Архангельская область' },
|
||||
{ code: 30, name: 'Астраханская область' },
|
||||
{ code: 31, name: 'Белгородская область' },
|
||||
{ code: 0, name: 'Вся РФ', federalDistrict: 0 },
|
||||
// 24 республики
|
||||
{ code: 1, name: 'Республика Адыгея', federalDistrict: 3 },
|
||||
{ code: 2, name: 'Республика Алтай', federalDistrict: 7 },
|
||||
{ code: 3, name: 'Республика Башкортостан', federalDistrict: 5 },
|
||||
{ code: 4, name: 'Республика Бурятия', federalDistrict: 8 },
|
||||
{ code: 5, name: 'Республика Дагестан', federalDistrict: 4 },
|
||||
{ code: 6, name: 'Донецкая Народная Республика', federalDistrict: 3 },
|
||||
{ code: 7, name: 'Республика Ингушетия', federalDistrict: 4 },
|
||||
{ code: 8, name: 'Кабардино-Балкарская Республика', federalDistrict: 4 },
|
||||
{ code: 9, name: 'Республика Калмыкия', federalDistrict: 3 },
|
||||
{ code: 10, name: 'Карачаево-Черкесская Республика', federalDistrict: 4 },
|
||||
{ code: 11, name: 'Республика Карелия', federalDistrict: 2 },
|
||||
{ code: 12, name: 'Республика Коми', federalDistrict: 2 },
|
||||
{ code: 13, name: 'Республика Крым', federalDistrict: 3 },
|
||||
{ code: 14, name: 'Луганская Народная Республика', federalDistrict: 3 },
|
||||
{ code: 15, name: 'Республика Марий Эл', federalDistrict: 5 },
|
||||
{ code: 16, name: 'Республика Мордовия', federalDistrict: 5 },
|
||||
{ code: 17, name: 'Республика Саха (Якутия)', federalDistrict: 8 },
|
||||
{ code: 18, name: 'Республика Северная Осетия — Алания', federalDistrict: 4 },
|
||||
{ code: 19, name: 'Республика Татарстан', federalDistrict: 5 },
|
||||
{ code: 20, name: 'Республика Тыва', federalDistrict: 7 },
|
||||
{ code: 21, name: 'Удмуртская Республика', federalDistrict: 5 },
|
||||
{ code: 22, name: 'Республика Хакасия', federalDistrict: 7 },
|
||||
{ code: 23, name: 'Чеченская Республика', federalDistrict: 4 },
|
||||
{ code: 24, name: 'Чувашская Республика', federalDistrict: 5 },
|
||||
// 9 краёв
|
||||
{ code: 25, name: 'Алтайский край', federalDistrict: 7 },
|
||||
{ code: 26, name: 'Забайкальский край', federalDistrict: 8 },
|
||||
{ code: 27, name: 'Камчатский край', federalDistrict: 8 },
|
||||
{ code: 28, name: 'Краснодарский край', federalDistrict: 3 },
|
||||
{ code: 29, name: 'Красноярский край', federalDistrict: 7 },
|
||||
{ code: 30, name: 'Пермский край', federalDistrict: 5 },
|
||||
{ code: 31, name: 'Приморский край', federalDistrict: 8 },
|
||||
{ code: 32, name: 'Ставропольский край', federalDistrict: 4 },
|
||||
{ code: 33, name: 'Хабаровский край', federalDistrict: 8 },
|
||||
// 48 областей
|
||||
{ code: 34, name: 'Амурская область', federalDistrict: 8 },
|
||||
{ code: 35, name: 'Архангельская область', federalDistrict: 2 },
|
||||
{ code: 36, name: 'Астраханская область', federalDistrict: 3 },
|
||||
{ code: 37, name: 'Белгородская область', federalDistrict: 1 },
|
||||
{ code: 38, name: 'Брянская область', federalDistrict: 1 },
|
||||
{ code: 39, name: 'Владимирская область', federalDistrict: 1 },
|
||||
{ code: 40, name: 'Волгоградская область', federalDistrict: 3 },
|
||||
{ code: 41, name: 'Вологодская область', federalDistrict: 2 },
|
||||
{ code: 42, name: 'Воронежская область', federalDistrict: 1 },
|
||||
{ code: 43, name: 'Запорожская область', federalDistrict: 3 },
|
||||
{ code: 44, name: 'Ивановская область', federalDistrict: 1 },
|
||||
{ code: 45, name: 'Иркутская область', federalDistrict: 7 },
|
||||
{ code: 46, name: 'Калининградская область', federalDistrict: 2 },
|
||||
{ code: 47, name: 'Калужская область', federalDistrict: 1 },
|
||||
{ code: 48, name: 'Кемеровская область', federalDistrict: 7 },
|
||||
{ code: 49, name: 'Кировская область', federalDistrict: 5 },
|
||||
{ code: 50, name: 'Костромская область', federalDistrict: 1 },
|
||||
{ code: 51, name: 'Курганская область', federalDistrict: 6 },
|
||||
{ code: 52, name: 'Курская область', federalDistrict: 1 },
|
||||
{ code: 53, name: 'Ленинградская область', federalDistrict: 2 },
|
||||
{ code: 54, name: 'Липецкая область', federalDistrict: 1 },
|
||||
{ code: 55, name: 'Магаданская область', federalDistrict: 8 },
|
||||
{ code: 56, name: 'Московская область', federalDistrict: 1 },
|
||||
{ code: 57, name: 'Мурманская область', federalDistrict: 2 },
|
||||
{ code: 58, name: 'Нижегородская область', federalDistrict: 5 },
|
||||
{ code: 59, name: 'Новгородская область', federalDistrict: 2 },
|
||||
{ code: 60, name: 'Новосибирская область', federalDistrict: 7 },
|
||||
{ code: 61, name: 'Омская область', federalDistrict: 7 },
|
||||
{ code: 62, name: 'Оренбургская область', federalDistrict: 5 },
|
||||
{ code: 63, name: 'Орловская область', federalDistrict: 1 },
|
||||
{ code: 64, name: 'Пензенская область', federalDistrict: 5 },
|
||||
{ code: 65, name: 'Псковская область', federalDistrict: 2 },
|
||||
{ code: 66, name: 'Ростовская область', federalDistrict: 3 },
|
||||
{ code: 67, name: 'Рязанская область', federalDistrict: 1 },
|
||||
{ code: 68, name: 'Самарская область', federalDistrict: 5 },
|
||||
{ code: 69, name: 'Саратовская область', federalDistrict: 5 },
|
||||
{ code: 70, name: 'Сахалинская область', federalDistrict: 8 },
|
||||
{ code: 71, name: 'Свердловская область', federalDistrict: 6 },
|
||||
{ code: 72, name: 'Смоленская область', federalDistrict: 1 },
|
||||
{ code: 73, name: 'Тамбовская область', federalDistrict: 1 },
|
||||
{ code: 74, name: 'Тверская область', federalDistrict: 1 },
|
||||
{ code: 75, name: 'Томская область', federalDistrict: 7 },
|
||||
{ code: 76, name: 'Тульская область', federalDistrict: 1 },
|
||||
{ code: 77, name: 'Тюменская область', federalDistrict: 6 },
|
||||
{ code: 78, name: 'Ульяновская область', federalDistrict: 5 },
|
||||
{ code: 79, name: 'Херсонская область', federalDistrict: 3 },
|
||||
{ code: 80, name: 'Челябинская область', federalDistrict: 6 },
|
||||
{ code: 81, name: 'Ярославская область', federalDistrict: 1 },
|
||||
// 3 города федерального значения
|
||||
{ code: 82, name: 'Москва', federalDistrict: 1 },
|
||||
{ code: 83, name: 'Санкт-Петербург', federalDistrict: 2 },
|
||||
{ code: 84, name: 'Севастополь', federalDistrict: 3 },
|
||||
// 1 автономная область
|
||||
{ code: 85, name: 'Еврейская автономная область', federalDistrict: 8 },
|
||||
// 4 автономных округа
|
||||
{ code: 86, name: 'Ненецкий автономный округ', federalDistrict: 2 },
|
||||
{ code: 87, name: 'Ханты-Мансийский автономный округ — Югра', federalDistrict: 6 },
|
||||
{ code: 88, name: 'Чукотский автономный округ', federalDistrict: 8 },
|
||||
{ code: 89, name: 'Ямало-Ненецкий автономный округ', federalDistrict: 6 },
|
||||
];
|
||||
|
||||
export const FEDERAL_DISTRICT_NAMES: Record<number, string> = {
|
||||
1: 'Центральный',
|
||||
2: 'Северо-Западный',
|
||||
3: 'Южный',
|
||||
4: 'Северо-Кавказский',
|
||||
5: 'Приволжский',
|
||||
6: 'Уральский',
|
||||
7: 'Сибирский',
|
||||
8: 'Дальневосточный',
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface Project {
|
||||
archived_at: string | null;
|
||||
region_mask?: number;
|
||||
region_mode?: string;
|
||||
regions?: number[]; // Plan 6 — subject codes 1..89; пустой массив = вся РФ
|
||||
delivery_days_mask?: number;
|
||||
sync_status: 'ok' | 'pending' | 'failed';
|
||||
last_synced_at?: string | null;
|
||||
@@ -105,6 +106,9 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
action: 'pause' | 'resume' | 'archive' | 'update_regions' | 'update_days' | 'update_limit';
|
||||
add?: number;
|
||||
remove?: number;
|
||||
// Plan 6.5 — update_regions оперирует кодами субъектов (1..89), не bitmask ФО.
|
||||
add_regions?: number[];
|
||||
remove_regions?: number[];
|
||||
delta?: number;
|
||||
replace?: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Workaround для бага позиционирования Vuetify connected-location-strategy.
|
||||
*
|
||||
* Когда активатор `v-select`/`v-autocomplete` находится внутри
|
||||
* `position: fixed`-контейнера (кастомный дровер, диалог), Vuetify включает
|
||||
* ветку `activatorFixed` (`isFixedPosition()` → true). Её `getIntrinsicSize()`
|
||||
* вычитает `el.style.left` из измеренной геометрии оверлея; на переходном
|
||||
* кадре, когда контент ещё отрисован в нулевой позиции, а инлайновый
|
||||
* `style.left` уже не нулевой, `contentBox.x` становится отрицательным и
|
||||
* стратегия аккумулирует смещение — меню уезжает на кратное X активатора
|
||||
* (за край экрана).
|
||||
*
|
||||
* Обычно гонку сглаживают пересчёты, размазанные по анимации открытия. Под
|
||||
* `prefers-reduced-motion: reduce` (умолчание Windows Server) анимации нет —
|
||||
* один пересчёт на «плохом» кадре остаётся финальным.
|
||||
*
|
||||
* Фикс: дождаться, пока контент оверлея отрисован и геометрически стабилен,
|
||||
* затем один раз послать `resize` — Vuetify пересчитает позицию по уже
|
||||
* устоявшейся геометрии и поставит меню корректно. Безопасно при motion ON
|
||||
* (пересчёт по стабильной геометрии идемпотентен) и для не-fixed контейнеров.
|
||||
*
|
||||
* Привязывать к `@update:menu` нужного `v-autocomplete`/`v-select`.
|
||||
*/
|
||||
export function repositionMenuAfterOpen(open: boolean): void {
|
||||
if (!open || typeof window === 'undefined') return;
|
||||
|
||||
let prevLeft = Number.NaN;
|
||||
let stableFrames = 0;
|
||||
let totalFrames = 0;
|
||||
|
||||
const tick = (): void => {
|
||||
// Последний открытый overlay-menu (на случай вложенных оверлеев).
|
||||
const menus = document.querySelectorAll<HTMLElement>('.v-overlay.v-menu .v-overlay__content');
|
||||
const el = menus[menus.length - 1];
|
||||
|
||||
if (el && el.getBoundingClientRect().width > 0) {
|
||||
const left = Math.round(el.getBoundingClientRect().left);
|
||||
stableFrames = left === prevLeft ? stableFrames + 1 : 0;
|
||||
prevLeft = left;
|
||||
// 3 кадра без движения = геометрия устоялась → один чистый пересчёт.
|
||||
if (stableFrames >= 3) {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Предохранитель ~1.5 c: не зацикливаться, если оверлей не появился.
|
||||
if (++totalFrames < 90) requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Канбан — альтернативный вид сделок (по статусам). 14 колонок (lead_statuses).
|
||||
* Канбан — альтернативный вид сделок (по статусам). 5 колонок (lead_statuses).
|
||||
*
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_kanban.html.
|
||||
* DnD реализован через vuedraggable@4 (обёртка SortableJS) — карточки можно
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Settings — настройки тенанта/пользователя. 8 вкладок (по v8.5 §13 + ТЗ §14).
|
||||
* Settings — настройки тенанта/пользователя. 4 рабочие вкладки.
|
||||
*
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_settings.html.
|
||||
* Полностью реализованы (с UI-разводкой): Профиль, Безопасность, API и Webhook,
|
||||
* Уведомления (матрица 8×3 по schema v8.7 §4 users.notification_preferences).
|
||||
* Placeholder-заглушки: Проекты, Команда, Интеграции, Тихие часы.
|
||||
*
|
||||
* Аудит D6/D7 (Sprint 3E, 2026-05-16): placeholder-вкладки Проекты/Команда/
|
||||
* Интеграции/Тихие часы убраны — UI не должен обещать «в разработке».
|
||||
* «Проекты» дублировали /projects; «Команда» и «Тихие часы» (ТЗ §17.8)
|
||||
* требуют schema+backend (отдельные эпики); «Интеграции» внешне-блокированы (Б-1).
|
||||
* Вкладки вернутся при реальной реализации соответствующих модулей.
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import ApiTab from './settings/ApiTab.vue';
|
||||
import NotificationsTab from './settings/NotificationsTab.vue';
|
||||
import PlaceholderTab from './settings/PlaceholderTab.vue';
|
||||
import ProfileTab from './settings/ProfileTab.vue';
|
||||
import SecurityTab from './settings/SecurityTab.vue';
|
||||
|
||||
@@ -23,41 +27,11 @@ interface Tab {
|
||||
const tabs: Tab[] = [
|
||||
{ id: 'profile', label: 'Профиль', icon: 'mdi-account-outline' },
|
||||
{ id: 'security', label: 'Безопасность', icon: 'mdi-shield-lock-outline' },
|
||||
{ id: 'projects', label: 'Проекты', icon: 'mdi-folder-outline' },
|
||||
{ id: 'team', label: 'Команда', icon: 'mdi-account-group-outline' },
|
||||
{ id: 'api', label: 'API и Webhook', icon: 'mdi-api' },
|
||||
{ id: 'integrations', label: 'Интеграции', icon: 'mdi-puzzle-outline' },
|
||||
{ id: 'hours', label: 'Тихие часы', icon: 'mdi-clock-outline' },
|
||||
{ id: 'notifications', label: 'Уведомления', icon: 'mdi-bell-outline' },
|
||||
];
|
||||
|
||||
const activeTab = ref('profile');
|
||||
|
||||
const placeholderProps = computed(() => {
|
||||
const map: Record<string, { title: string; description: string }> = {
|
||||
projects: {
|
||||
title: 'Проекты',
|
||||
description:
|
||||
'Управление проектами тенанта (макс. 10 на тарифе «Команда»). Для каждого проекта — поставщик ГЦК, цена за лид, активные UTM-кампании.',
|
||||
},
|
||||
team: {
|
||||
title: 'Команда',
|
||||
description:
|
||||
'Менеджеры тенанта (макс. 4 + расширение). Назначение прав, автораспределение, ограничение доступа к проектам.',
|
||||
},
|
||||
integrations: {
|
||||
title: 'Интеграции',
|
||||
description:
|
||||
'Подключение Telegram-бота для нотификаций, экспорт в 1С 8.3, JivoSite helpdesk, Yandex 360 SSO.',
|
||||
},
|
||||
hours: {
|
||||
title: 'Тихие часы',
|
||||
description:
|
||||
'Расписание, в которое не приходят SMS/звонки автонапоминаний (например, 22:00-08:00 + выходные).',
|
||||
},
|
||||
};
|
||||
return map[activeTab.value];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -91,11 +65,6 @@ const placeholderProps = computed(() => {
|
||||
<SecurityTab v-else-if="activeTab === 'security'" />
|
||||
<ApiTab v-else-if="activeTab === 'api'" />
|
||||
<NotificationsTab v-else-if="activeTab === 'notifications'" />
|
||||
<PlaceholderTab
|
||||
v-else-if="placeholderProps"
|
||||
:title="placeholderProps.title"
|
||||
:description="placeholderProps.description"
|
||||
/>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
@@ -76,12 +76,35 @@
|
||||
:error-messages="errors.daily_limit_target"
|
||||
/>
|
||||
|
||||
<v-autocomplete
|
||||
v-model="form.regions"
|
||||
:items="selectableRegions"
|
||||
item-title="name"
|
||||
item-value="code"
|
||||
label="Регионы (пусто = вся РФ)"
|
||||
multiple
|
||||
chips
|
||||
clearable
|
||||
density="comfortable"
|
||||
class="ld-input-quiet"
|
||||
data-testid="regions-autocomplete"
|
||||
@update:menu="repositionMenuAfterOpen"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<v-list-item v-bind="itemProps">
|
||||
<template #subtitle>
|
||||
{{ FEDERAL_DISTRICT_NAMES[item.raw.federalDistrict] || '' }}
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
|
||||
<v-alert
|
||||
v-if="generalError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
class="mt-3"
|
||||
closable
|
||||
@click:close="generalError = null"
|
||||
>
|
||||
@@ -114,9 +137,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue';
|
||||
import { apiClient, ensureCsrfCookie, extractErrorMessage } from '../../api/client';
|
||||
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
|
||||
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
|
||||
import type { Project } from '../../stores/projectsStore';
|
||||
import DevIndexBadge from '../../components/DevIndexBadge.vue';
|
||||
|
||||
const selectableRegions = REGIONS.filter((r) => r.code !== 0);
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
mode?: 'create' | 'edit';
|
||||
@@ -124,9 +151,8 @@ const props = defineProps<{
|
||||
}>();
|
||||
const emit = defineEmits(['update:modelValue', 'saved']);
|
||||
|
||||
// region_mask=255 = все 8 ФО (schema default, см. db/schema.sql §projects).
|
||||
// PDD regions UI отключён до закрытия Plan 6 — конфликт с 8-битной ФО-маской
|
||||
// в PhonePrefixService.php (1 phone prefix ↔ 1 ФО, не субъект).
|
||||
// Plan 6: regions = subject codes (1..89) — backend dual-writes region_mask/region_mode.
|
||||
// Пустой массив = вся РФ.
|
||||
const form = reactive({
|
||||
name: '',
|
||||
signal_type: 'site' as 'site' | 'call' | 'sms',
|
||||
@@ -134,8 +160,7 @@ const form = reactive({
|
||||
sms_senders: [] as string[],
|
||||
sms_keyword: '',
|
||||
daily_limit_target: 50,
|
||||
region_mask: 255,
|
||||
region_mode: 'include' as 'include' | 'exclude',
|
||||
regions: [] as number[],
|
||||
delivery_days_mask: 127,
|
||||
});
|
||||
const errors = reactive<Record<string, string[]>>({});
|
||||
@@ -159,6 +184,7 @@ watch(
|
||||
if (open) generalError.value = null;
|
||||
if (open && props.mode === 'edit' && props.project) {
|
||||
Object.assign(form, props.project);
|
||||
form.regions = Array.isArray(props.project.regions) ? [...props.project.regions] : [];
|
||||
const days: number[] = [];
|
||||
for (let i = 0; i < 7; i++) if (form.delivery_days_mask & (1 << i)) days.push(i);
|
||||
selectedDays.value = days;
|
||||
@@ -170,8 +196,7 @@ watch(
|
||||
sms_senders: [],
|
||||
sms_keyword: '',
|
||||
daily_limit_target: 50,
|
||||
region_mask: 255,
|
||||
region_mode: 'include',
|
||||
regions: [],
|
||||
delivery_days_mask: 127,
|
||||
});
|
||||
selectedDays.value = [0, 1, 2, 3, 4, 5, 6];
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Универсальный placeholder для ещё-не-реализованных вкладок Settings.
|
||||
* Используется для вкладок: Проекты, Команда, Интеграции, Тихие часы.
|
||||
*
|
||||
* При реализации каждой вкладки — заменяется на отдельный component.
|
||||
*/
|
||||
defineProps<{ title: string; description: string }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tab-content">
|
||||
<h2 class="tab-title text-h6 mb-3">{{ title }}</h2>
|
||||
<v-alert type="info" variant="tonal" density="compact" class="mb-4">
|
||||
<strong>В разработке.</strong> Этот раздел реализуется в следующих коммитах.
|
||||
</v-alert>
|
||||
<p class="text-body-2 text-medium-emphasis">{{ description }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tab-title {
|
||||
font-variation-settings: 'opsz' 18;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
</style>
|
||||
@@ -166,7 +166,7 @@ test('GET show: activity возвращает с actor_email из users LEFT JOI
|
||||
'user_id' => $user->id,
|
||||
'deal_id' => 999,
|
||||
'event' => 'deal.status_changed',
|
||||
'context' => json_encode(['from' => 'new', 'to' => 'worked']),
|
||||
'context' => json_encode(['from' => 'new', 'to' => 'in_progress']),
|
||||
'created_at' => Carbon::now(),
|
||||
]);
|
||||
DB::table('activity_log')->insert([
|
||||
|
||||
@@ -6,17 +6,17 @@ use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
|
||||
it('accepts update_regions action with add/remove bitmask', function () {
|
||||
it('accepts update_regions action with subject-code arrays', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->for($tenant)->create();
|
||||
$p = Project::factory()->for($tenant)->create(['region_mask' => 1]);
|
||||
$p = Project::factory()->for($tenant)->create(['regions' => [82]]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/api/projects/bulk', [
|
||||
'action' => 'update_regions',
|
||||
'ids' => [$p->id],
|
||||
'add' => 6, // биты 2+4 = Северо-Западный + Южный
|
||||
'remove' => 1, // бит 1 = Центральный
|
||||
'add_regions' => [83, 84], // Санкт-Петербург + Севастополь
|
||||
'remove_regions' => [82], // Москва
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonStructure(['updated', 'skipped', 'warnings']);
|
||||
@@ -69,24 +69,39 @@ it('accepts empty scope.filter as valid scope (all projects)', function () {
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('applies update_regions add and remove correctly', function () {
|
||||
it('applies update_regions add_regions and remove_regions to the regions array', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->for($tenant)->create();
|
||||
$p1 = Project::factory()->for($tenant)->create(['region_mask' => 3]); // 1+2
|
||||
$p2 = Project::factory()->for($tenant)->create(['region_mask' => 5]); // 1+4
|
||||
$p1 = Project::factory()->for($tenant)->create(['regions' => [82, 56]]); // Москва + Московская обл.
|
||||
$p2 = Project::factory()->for($tenant)->create(['regions' => []]); // вся РФ
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/api/projects/bulk', [
|
||||
'action' => 'update_regions',
|
||||
'ids' => [$p1->id, $p2->id],
|
||||
'add' => 16, // 16 = Приволжский
|
||||
'remove' => 1, // 1 = Центральный
|
||||
'add_regions' => [83], // Санкт-Петербург
|
||||
'remove_regions' => [56], // Московская область
|
||||
])
|
||||
->assertOk()
|
||||
->assertJson(['updated' => 2, 'skipped' => [], 'warnings' => []]);
|
||||
|
||||
expect($p1->fresh()->region_mask)->toBe((3 | 16) & ~1); // = 18
|
||||
expect($p2->fresh()->region_mask)->toBe((5 | 16) & ~1); // = 20
|
||||
expect($p1->fresh()->regions)->toBe([82, 83]); // [82,56] ∪ {83} \ {56}, отсортировано
|
||||
expect($p2->fresh()->regions)->toBe([83]); // [] ∪ {83} \ {56}
|
||||
});
|
||||
|
||||
it('rejects update_regions with out-of-range subject code', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->for($tenant)->create();
|
||||
$p = Project::factory()->for($tenant)->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/api/projects/bulk', [
|
||||
'action' => 'update_regions',
|
||||
'ids' => [$p->id],
|
||||
'add_regions' => [90], // > 89 — невалидный код субъекта РФ
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['add_regions.0']);
|
||||
});
|
||||
|
||||
it('applies update_days add and remove correctly', function () {
|
||||
|
||||
@@ -71,7 +71,7 @@ it('leads_received считает только сделки окна, без del
|
||||
// 3 живые сделки в окне 7d + 1 deleted + 1 is_test + 1 вне окна (8 дней назад)
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(2));
|
||||
makeDashboardDeal($tenant, $project, 'paid', now()->subDays(3));
|
||||
makeDashboardDeal($tenant, $project, 'won', now()->subDays(3));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1), deletedAt: now());
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1), isTest: true);
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(8));
|
||||
@@ -81,14 +81,14 @@ it('leads_received считает только сделки окна, без del
|
||||
->assertJsonPath('leads_received.value', 3);
|
||||
});
|
||||
|
||||
it('conversion = доля статуса paid в окне', function () {
|
||||
it('conversion = доля статуса won в окне', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
makeDashboardDeal($tenant, $project, 'paid', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'won', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
// 1 paid из 4 → 25.0%; PHP json_encode кодирует 25.0 как 25 (без дроби)
|
||||
// 1 won из 4 → 25.0%; PHP json_encode кодирует 25.0 как 25 (без дроби)
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('conversion.value', 25);
|
||||
@@ -111,11 +111,11 @@ it('funnel группирует живые сделки по статусу', fu
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'paid', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'won', now()->subDays(1));
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('funnel.new', 2)
|
||||
->assertJsonPath('funnel.paid', 1);
|
||||
->assertJsonPath('funnel.won', 1);
|
||||
});
|
||||
|
||||
it('activity возвращает 7 точек и 7 меток', function () {
|
||||
|
||||
@@ -10,7 +10,6 @@ use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
@@ -194,160 +193,3 @@ test('POST /api/deals manual БЕЗ supplier'."'а у проекта — без
|
||||
->count();
|
||||
expect($cost)->toBe(0);
|
||||
});
|
||||
|
||||
test('POST /api/deals/export возвращает CSV с правильными headers + BOM', function () {
|
||||
// Создаём 2 сделки через store endpoint (получаем реальные id).
|
||||
$r1 = $this->postJson('/api/deals', [
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 111-11-11',
|
||||
'contact_name' => 'Алиса',
|
||||
])->json('deal');
|
||||
$r2 = $this->postJson('/api/deals', [
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 222-22-22',
|
||||
'contact_name' => 'Боб',
|
||||
])->json('deal');
|
||||
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'ids' => [$r1['id'], $r2['id']],
|
||||
]);
|
||||
|
||||
$r->assertStatus(200);
|
||||
expect($r->headers->get('Content-Type'))->toContain('text/csv');
|
||||
expect($r->headers->get('Content-Disposition'))->toContain('deals_export_');
|
||||
|
||||
// Sprint 3 Phase A (O-perf-05): export → StreamedResponse через OpenSpout,
|
||||
// body читается через streamedContent() (см. TestResponse::streamedContent).
|
||||
$body = $r->streamedContent();
|
||||
// BOM первый символ
|
||||
expect($body)->toStartWith("\u{FEFF}");
|
||||
// Headers строка
|
||||
expect($body)->toContain('ID;Имя;Телефон;Статус');
|
||||
// Контент сделок
|
||||
expect($body)->toContain('Алиса');
|
||||
expect($body)->toContain('Боб');
|
||||
expect($body)->toContain('+7 (999) 111-11-11');
|
||||
});
|
||||
|
||||
test('POST /api/deals/export 422 без ids', function () {
|
||||
$r = $this->postJson('/api/deals/export', []);
|
||||
$r->assertStatus(422);
|
||||
expect($r->json('errors'))->toHaveKey('ids');
|
||||
});
|
||||
|
||||
test('POST /api/deals/export 401 без auth', function () {
|
||||
auth()->logout();
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'ids' => [1, 2, 3],
|
||||
]);
|
||||
$r->assertStatus(401);
|
||||
});
|
||||
|
||||
test('POST /api/deals/export фильтрует только запрошенные ids (своего tenant\'а)', function () {
|
||||
// Создаём 3 сделки одного tenant'а, экспортируем 1 → CSV только её.
|
||||
$a = $this->postJson('/api/deals', [
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 111-11-11',
|
||||
'contact_name' => 'Алиса',
|
||||
])->json('deal');
|
||||
$this->postJson('/api/deals', [
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 222-22-22',
|
||||
'contact_name' => 'Боб',
|
||||
])->json('deal');
|
||||
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'ids' => [$a['id']],
|
||||
]);
|
||||
$r->assertStatus(200);
|
||||
$body = $r->streamedContent();
|
||||
expect($body)->toContain('Алиса');
|
||||
expect($body)->not->toContain('Боб');
|
||||
});
|
||||
|
||||
// NB: полная RLS-изоляция (другие tenant'ы скрыты) тестируется отдельно
|
||||
// через testing_rls_user (NOLOGIN role без BYPASSRLS) — см.
|
||||
// `tests/Feature/RlsSmokeTest.php` v1.10. В этом тесте используется postgres
|
||||
// superuser, который BYPASSRLS — RLS-проверка тут была бы false-positive.
|
||||
|
||||
test('POST /api/deals/export?format=xlsx возвращает binary с корректным content-type', function () {
|
||||
$a = $this->postJson('/api/deals', [
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 111-11-11',
|
||||
'contact_name' => 'Алиса',
|
||||
])->json('deal');
|
||||
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'ids' => [$a['id']],
|
||||
'format' => 'xlsx',
|
||||
]);
|
||||
|
||||
$r->assertStatus(200);
|
||||
expect($r->headers->get('Content-Type'))
|
||||
->toBe('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
expect($r->headers->get('Content-Disposition'))->toContain('.xlsx');
|
||||
// XLSX = ZIP-archive, начинается с magic bytes "PK\x03\x04".
|
||||
$body = $r->streamedContent();
|
||||
expect(substr($body, 0, 4))->toBe("PK\x03\x04");
|
||||
expect(strlen($body))->toBeGreaterThan(2000); // sanity: реальный xlsx > 2 KB
|
||||
});
|
||||
|
||||
test('POST /api/deals/export?format=xlsx содержит данные сделки (после распаковки sheet1)', function () {
|
||||
$a = $this->postJson('/api/deals', [
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 333-33-33',
|
||||
'contact_name' => 'Кириллов',
|
||||
])->json('deal');
|
||||
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'ids' => [$a['id']],
|
||||
'format' => 'xlsx',
|
||||
]);
|
||||
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_test_');
|
||||
file_put_contents($tmp, $r->streamedContent());
|
||||
$reader = IOFactory::createReader('Xlsx');
|
||||
$book = $reader->load($tmp);
|
||||
$sheet = $book->getActiveSheet();
|
||||
|
||||
expect($sheet->getTitle())->toBe('Сделки');
|
||||
// Sprint 3 Phase A (O-perf-05): после миграции на OpenSpout streaming,
|
||||
// styled-header cells пишутся как inline-string с RichText. Используем
|
||||
// getFormattedValue() для plain-string сравнения header'ов; для data-cell'ов
|
||||
// OpenSpout продолжает писать обычные shared-strings.
|
||||
expect($sheet->getCell('A1')->getFormattedValue())->toBe('ID');
|
||||
expect($sheet->getCell('B1')->getFormattedValue())->toBe('Имя');
|
||||
expect($sheet->getStyle('A1')->getFont()->getBold())->toBeTrue();
|
||||
// Row 2 — реальная сделка. OpenSpout пишет string-cell'ы как inline-string с
|
||||
// RichText-обёрткой; для plain-string сравнения используем getFormattedValue().
|
||||
// Numeric cell A2 (ID) — обычный numeric, ->getValue() работает.
|
||||
expect($sheet->getCell('A2')->getValue())->toBe($a['id']);
|
||||
expect($sheet->getCell('B2')->getFormattedValue())->toBe('Кириллов');
|
||||
expect($sheet->getCell('C2')->getFormattedValue())->toBe('+7 (999) 333-33-33');
|
||||
|
||||
unlink($tmp);
|
||||
});
|
||||
|
||||
test('POST /api/deals/export 422 на неизвестный format', function () {
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'ids' => [1],
|
||||
'format' => 'pdf',
|
||||
]);
|
||||
$r->assertStatus(422);
|
||||
expect($r->json('errors'))->toHaveKey('format');
|
||||
});
|
||||
|
||||
test('POST /api/deals/export по умолчанию (без format) возвращает CSV — backward-compat', function () {
|
||||
$a = $this->postJson('/api/deals', [
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 444-44-44',
|
||||
'contact_name' => 'Test',
|
||||
])->json('deal');
|
||||
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'ids' => [$a['id']],
|
||||
]);
|
||||
$r->assertStatus(200);
|
||||
expect($r->headers->get('Content-Type'))->toContain('text/csv');
|
||||
expect($r->headers->get('Content-Disposition'))->toContain('.csv');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Тесты POST /api/deals/export — экспорт по диапазону дат поставки.
|
||||
*
|
||||
* Редизайн «Сделки» (2026-05-17, Task A5): вместо ids[] — received_from/received_to.
|
||||
* Конвенции: DatabaseTransactions + actingAs + SET app.current_tenant_id
|
||||
* (аналогично DealIndexTest.php).
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$this->project = Project::factory()->for($this->tenant)->create(['name' => 'Окна Москва']);
|
||||
});
|
||||
|
||||
test('POST /api/deals/export требует auth', function () {
|
||||
auth()->logout();
|
||||
$this->postJson('/api/deals/export', ['format' => 'csv'])->assertStatus(401);
|
||||
});
|
||||
|
||||
test('POST /api/deals/export csv возвращает сделки в диапазоне дат', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create([
|
||||
'phone' => '+7 999 111-11-11', 'received_at' => '2026-05-15 10:00:00',
|
||||
]);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create([
|
||||
'phone' => '+7 999 222-22-22', 'received_at' => '2026-05-25 10:00:00',
|
||||
]);
|
||||
|
||||
$r = $this->post('/api/deals/export', [
|
||||
'received_from' => '2026-05-14', 'received_to' => '2026-05-16', 'format' => 'csv',
|
||||
]);
|
||||
|
||||
$r->assertStatus(200);
|
||||
$r->assertHeader('content-type', 'text/csv; charset=utf-8');
|
||||
$body = $r->streamedContent();
|
||||
expect($body)->toContain('+7 999 111-11-11');
|
||||
expect($body)->not->toContain('+7 999 222-22-22');
|
||||
});
|
||||
|
||||
test('POST /api/deals/export xlsx отдаёт spreadsheet content-type', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-15 10:00:00']);
|
||||
|
||||
$r = $this->post('/api/deals/export', ['format' => 'xlsx']);
|
||||
|
||||
$r->assertStatus(200);
|
||||
$r->assertHeader('content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
});
|
||||
|
||||
test('POST /api/deals/export не экспортирует чужой tenant (RLS)', function () {
|
||||
$other = Tenant::factory()->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$other->id);
|
||||
$foreignProject = Project::factory()->for($other)->create();
|
||||
Deal::factory()->for($other)->for($foreignProject)->create(['phone' => '+7 900 000-00-00']);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$r = $this->post('/api/deals/export', ['format' => 'csv']);
|
||||
|
||||
expect($r->streamedContent())->not->toContain('+7 900 000-00-00');
|
||||
});
|
||||
|
||||
test('POST /api/deals/export 422 на неизвестный format', function () {
|
||||
$this->postJson('/api/deals/export', ['format' => 'pdf'])->assertStatus(422);
|
||||
});
|
||||
|
||||
test('POST /api/deals/export без format по умолчанию отдаёт CSV', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-15 10:00:00']);
|
||||
$r = $this->post('/api/deals/export', []);
|
||||
$r->assertStatus(200);
|
||||
$r->assertHeader('content-type', 'text/csv; charset=utf-8');
|
||||
});
|
||||
@@ -105,14 +105,14 @@ test('GET /api/deals сортирует по received_at DESC', function () {
|
||||
|
||||
test('GET /api/deals фильтрует по status_in[]', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'closed']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'won']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'lost']);
|
||||
|
||||
$r = $this->getJson('/api/deals?status_in[]=new&status_in[]=paid');
|
||||
$r = $this->getJson('/api/deals?status_in[]=new&status_in[]=won');
|
||||
|
||||
expect($r->json('total'))->toBe(2);
|
||||
$statuses = collect($r->json('deals'))->pluck('status')->sort()->values()->all();
|
||||
expect($statuses)->toBe(['new', 'paid']);
|
||||
expect($statuses)->toBe(['new', 'won']);
|
||||
});
|
||||
|
||||
test('GET /api/deals фильтрует по project_id', function () {
|
||||
@@ -292,7 +292,7 @@ test('GET /api/deals возвращает next_cursor когда есть ещё
|
||||
|
||||
test('GET /api/deals?count_only=1 возвращает только total без массива deals', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'won']);
|
||||
|
||||
$r = $this->getJson('/api/deals?count_only=1');
|
||||
|
||||
@@ -304,7 +304,7 @@ test('GET /api/deals?count_only=1 возвращает только total без
|
||||
test('GET /api/deals?count_only=1 учитывает фильтры (status_in)', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'won']);
|
||||
|
||||
expect($this->getJson('/api/deals?count_only=1&status_in[]=new')->json('total'))->toBe(2);
|
||||
});
|
||||
@@ -318,3 +318,42 @@ test('GET /api/deals?count_only=1 изолирует чужой tenant (RLS)', f
|
||||
|
||||
expect($this->getJson('/api/deals?count_only=1')->json('total'))->toBe(1);
|
||||
});
|
||||
|
||||
test('GET /api/deals фильтрует по received_from/received_to', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-10 12:00:00']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-15 12:00:00']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-20 12:00:00']);
|
||||
|
||||
$r = $this->getJson('/api/deals?received_from=2026-05-12&received_to=2026-05-16');
|
||||
|
||||
expect($r->json('total'))->toBe(1);
|
||||
});
|
||||
|
||||
test('GET /api/deals received_to включает весь день (конец дня)', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-16 23:30:00']);
|
||||
|
||||
expect($this->getJson('/api/deals?received_to=2026-05-16')->json('total'))->toBe(1);
|
||||
});
|
||||
|
||||
test('GET /api/deals возвращает comment/city/project_signal_type/next_reminder_at', function () {
|
||||
$this->project->update(['signal_type' => 'call', 'signal_identifier' => '79990001122']);
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create([
|
||||
'comment' => 'перезвонить',
|
||||
'city' => 'Казань',
|
||||
]);
|
||||
|
||||
$r = $this->getJson('/api/deals');
|
||||
|
||||
expect($r->json('deals.0.comment'))->toBe('перезвонить');
|
||||
expect($r->json('deals.0.city'))->toBe('Казань');
|
||||
expect($r->json('deals.0.project_signal_type'))->toBe('call');
|
||||
expect($r->json('deals.0'))->toHaveKey('next_reminder_at');
|
||||
});
|
||||
|
||||
test('GET /api/deals возвращает 422 на невалидную received_from', function () {
|
||||
$this->getJson('/api/deals?received_from=не-дата')->assertStatus(422);
|
||||
});
|
||||
|
||||
test('GET /api/deals возвращает 422 на невалидную received_to', function () {
|
||||
$this->getJson('/api/deals?received_to=garbage')->assertStatus(422);
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ test('GET /api/deals/{id} возвращает activity events отсортир
|
||||
'user_id' => $this->manager->id,
|
||||
'deal_id' => $deal->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
|
||||
'context' => ['from' => 'new', 'to' => 'paid', 'source' => 'manual'],
|
||||
'context' => ['from' => 'new', 'to' => 'won', 'source' => 'manual'],
|
||||
'created_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
@@ -106,7 +106,7 @@ test('GET /api/deals/{id} возвращает activity events отсортир
|
||||
expect($events)->toHaveCount(2);
|
||||
// ORDER BY created_at DESC — свежее (status_changed) сверху.
|
||||
expect($events[0]['event'])->toBe('deal.status_changed');
|
||||
expect($events[0]['context'])->toMatchArray(['from' => 'new', 'to' => 'paid']);
|
||||
expect($events[0]['context'])->toMatchArray(['from' => 'new', 'to' => 'won']);
|
||||
expect($events[0]['actor']['name'])->toBe('Иван П.');
|
||||
expect($events[0]['actor']['initials'])->toBe('ИП');
|
||||
|
||||
|
||||
@@ -61,18 +61,18 @@ test('POST /api/deals/transition — обновляет статус и пише
|
||||
|
||||
$r = $this->postJson('/api/deals/transition', [
|
||||
'ids' => $deals->pluck('id')->all(),
|
||||
'status' => 'paid',
|
||||
'status' => 'won',
|
||||
]);
|
||||
|
||||
$r->assertStatus(200)->assertJson([
|
||||
'updated' => 3,
|
||||
'requested' => 3,
|
||||
'status' => 'paid',
|
||||
'status' => 'won',
|
||||
]);
|
||||
|
||||
foreach ($deals as $d) {
|
||||
$d->refresh();
|
||||
expect($d->status)->toBe('paid');
|
||||
expect($d->status)->toBe('won');
|
||||
}
|
||||
|
||||
$activity = ActivityLog::where('tenant_id', $this->tenant->id)
|
||||
@@ -81,17 +81,17 @@ test('POST /api/deals/transition — обновляет статус и пише
|
||||
expect($activity)->toHaveCount(3);
|
||||
expect($activity->first()->context)->toMatchArray([
|
||||
'from' => 'new',
|
||||
'to' => 'paid',
|
||||
'to' => 'won',
|
||||
'source' => 'bulk',
|
||||
]);
|
||||
});
|
||||
|
||||
test('POST /api/deals/transition — NO-OP не пишет ActivityLog', function () {
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'won']);
|
||||
|
||||
$r = $this->postJson('/api/deals/transition', [
|
||||
'ids' => [$deal->id],
|
||||
'status' => 'paid',
|
||||
'status' => 'won',
|
||||
]);
|
||||
|
||||
$r->assertStatus(200)->assertJson(['updated' => 0, 'requested' => 1]);
|
||||
@@ -111,7 +111,7 @@ test('POST /api/deals/transition — defense-in-depth не апдейтит чу
|
||||
// Передаём оба id — чужой не должен обновиться.
|
||||
$r = $this->postJson('/api/deals/transition', [
|
||||
'ids' => [$own->id, $foreign->id],
|
||||
'status' => 'paid',
|
||||
'status' => 'won',
|
||||
]);
|
||||
|
||||
$r->assertStatus(200)->assertJson([
|
||||
@@ -121,7 +121,7 @@ test('POST /api/deals/transition — defense-in-depth не апдейтит чу
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$own->refresh();
|
||||
expect($own->status)->toBe('paid');
|
||||
expect($own->status)->toBe('won');
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id);
|
||||
$foreign->refresh();
|
||||
@@ -131,6 +131,6 @@ test('POST /api/deals/transition — defense-in-depth не апдейтит чу
|
||||
test('POST /api/deals/transition — 422 если ids пустой массив', function () {
|
||||
$this->postJson('/api/deals/transition', [
|
||||
'ids' => [],
|
||||
'status' => 'paid',
|
||||
'status' => 'won',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
@@ -83,17 +83,17 @@ test('PATCH /api/deals/{id} обновляет status + пишет deal.status_c
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||||
|
||||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||||
'status' => 'paid',
|
||||
'status' => 'won',
|
||||
]);
|
||||
$r->assertStatus(200);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$deal->refresh();
|
||||
expect($deal->status)->toBe('paid');
|
||||
expect($deal->status)->toBe('won');
|
||||
|
||||
$log = ActivityLog::where('deal_id', $deal->id)->where('event', 'deal.status_changed')->first();
|
||||
expect($log)->not->toBeNull();
|
||||
expect($log->context)->toMatchArray(['from' => 'new', 'to' => 'paid', 'source' => 'manual']);
|
||||
expect($log->context)->toMatchArray(['from' => 'new', 'to' => 'won', 'source' => 'manual']);
|
||||
});
|
||||
|
||||
test('PATCH /api/deals/{id} 422 на неизвестный status slug', function () {
|
||||
@@ -123,12 +123,12 @@ test('PATCH /api/deals/{id} 422 на manager_id чужого tenant\'а', functi
|
||||
|
||||
test('PATCH /api/deals/{id} NO-OP не пишет ActivityLog', function () {
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create([
|
||||
'status' => 'paid',
|
||||
'status' => 'won',
|
||||
'comment' => 'same',
|
||||
]);
|
||||
|
||||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||||
'status' => 'paid', // не меняем
|
||||
'status' => 'won', // не меняем
|
||||
'comment' => 'same', // не меняем
|
||||
]);
|
||||
$r->assertStatus(200);
|
||||
@@ -145,7 +145,7 @@ test('PATCH /api/deals/{id} комбинированно — comment + status о
|
||||
|
||||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||||
'comment' => 'Заметка',
|
||||
'status' => 'worked',
|
||||
'status' => 'in_progress',
|
||||
]);
|
||||
$r->assertStatus(200);
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ test('импортирует исторические лиды, создавая
|
||||
->and($result->updated)->toBe(0);
|
||||
|
||||
$deal = Deal::query()->where('source_crm_id', 5001)->firstOrFail();
|
||||
expect($deal->status)->toBe('negotiations')
|
||||
expect($deal->status)->toBe('in_progress')
|
||||
->and($deal->phone)->toBe('79161112233')
|
||||
->and($deal->received_at->format('Y-m-d'))->toBe('2023-07-10');
|
||||
});
|
||||
@@ -89,7 +89,7 @@ test('повторный импорт того же файла не создаё
|
||||
->and(Deal::query()->where('source_crm_id', 5003)->count())->toBe(1);
|
||||
|
||||
$deal = Deal::query()->where('source_crm_id', 5003)->firstOrFail();
|
||||
expect($deal->status)->toBe('paid') // §6.5 стадия 3a: status перезаписан
|
||||
expect($deal->status)->toBe('won') // §6.5 стадия 3a: status перезаписан
|
||||
->and($deal->contact_name)->toBe('Пётр')
|
||||
->and($deal->comment)->toBe('Обновлённый');
|
||||
});
|
||||
@@ -127,7 +127,7 @@ test('resolved-маппинг tenant-а применяется к ранее н
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'status_ru' => 'Архив',
|
||||
'occurrences' => 1,
|
||||
'mapped_to_slug' => 'closed',
|
||||
'mapped_to_slug' => 'lost',
|
||||
'resolved_at' => now(),
|
||||
]);
|
||||
$rows = parseFixture(
|
||||
@@ -135,7 +135,7 @@ test('resolved-маппинг tenant-а применяется к ранее н
|
||||
);
|
||||
$this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);
|
||||
|
||||
expect(Deal::query()->where('source_crm_id', 5007)->firstOrFail()->status)->toBe('closed');
|
||||
expect(Deal::query()->where('source_crm_id', 5007)->firstOrFail()->status)->toBe('lost');
|
||||
});
|
||||
|
||||
test('dry_run не пишет сделки, но считает проекцию', function (): void {
|
||||
@@ -155,7 +155,7 @@ test('неизвестные статусы и resolved-маппинг изол
|
||||
'tenant_id' => $otherTenant->id,
|
||||
'status_ru' => 'Архив',
|
||||
'occurrences' => 9,
|
||||
'mapped_to_slug' => 'closed',
|
||||
'mapped_to_slug' => 'lost',
|
||||
'resolved_at' => now(),
|
||||
]);
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ test('GET /api/imports/unknown-statuses возвращает незамапле
|
||||
]);
|
||||
ImportUnknownStatus::create([
|
||||
'tenant_id' => $this->tenant->id, 'status_ru' => 'Спам', 'occurrences' => 1,
|
||||
'mapped_to_slug' => 'closed', 'resolved_at' => now(),
|
||||
'mapped_to_slug' => 'lost', 'resolved_at' => now(),
|
||||
]);
|
||||
|
||||
$this->getJson('/api/imports/unknown-statuses')
|
||||
@@ -113,10 +113,10 @@ test('POST /api/imports/unknown-statuses/resolve проставляет мапп
|
||||
]);
|
||||
|
||||
$this->postJson('/api/imports/unknown-statuses/resolve', [
|
||||
'mappings' => [['status_ru' => 'Архив', 'slug' => 'closed']],
|
||||
'mappings' => [['status_ru' => 'Архив', 'slug' => 'lost']],
|
||||
])->assertStatus(200);
|
||||
|
||||
expect($unknown->refresh()->mapped_to_slug)->toBe('closed')
|
||||
expect($unknown->refresh()->mapped_to_slug)->toBe('lost')
|
||||
->and($unknown->resolved_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ test('ImportUnknownStatus хранит маппинг и фильтруется
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'status_ru' => 'Спам',
|
||||
'occurrences' => 1,
|
||||
'mapped_to_slug' => 'closed',
|
||||
'mapped_to_slug' => 'lost',
|
||||
'resolved_at' => now(),
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
test('lead_statuses содержит ровно 5 статусов воронки', function () {
|
||||
$slugs = DB::table('lead_statuses')->orderBy('sort_order')->pluck('slug')->all();
|
||||
expect($slugs)->toBe(['new', 'viewed', 'in_progress', 'won', 'lost']);
|
||||
});
|
||||
|
||||
test('новые статусы имеют корректные русские названия', function () {
|
||||
$names = DB::table('lead_statuses')->pluck('name_ru', 'slug');
|
||||
expect($names['new'])->toBe('Новая сделка');
|
||||
expect($names['in_progress'])->toBe('В работе');
|
||||
expect($names['won'])->toBe('Сделка');
|
||||
expect($names['lost'])->toBe('Не реализовано');
|
||||
});
|
||||
|
||||
test('старых slug-ов воронки в lead_statuses не осталось', function () {
|
||||
$obsolete = DB::table('lead_statuses')
|
||||
->whereIn('slug', ['worked', 'paid', 'closed', 'hot', 'negotiations'])
|
||||
->count();
|
||||
expect($obsolete)->toBe(0);
|
||||
});
|
||||
@@ -8,9 +8,8 @@ use Illuminate\Support\Facades\DB;
|
||||
/**
|
||||
* Тесты GET /api/lead-statuses — глобальный lookup статусов воронки.
|
||||
*
|
||||
* Таблица lead_statuses не tenant-aware, seeded в schema.sql:2130 (14 системных
|
||||
* статусов: new/viewed/worked/base/missed/negotiations/waiting_payment/
|
||||
* partnership/paid/closed/test_drive/hot/replacement/final_missed).
|
||||
* Таблица lead_statuses не tenant-aware, seeded в schema.sql (5 системных
|
||||
* статусов воронки: new/viewed/in_progress/won/lost).
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
@@ -19,18 +18,14 @@ test('GET /api/lead-statuses возвращает 200 и не пустой сп
|
||||
|
||||
$r->assertStatus(200);
|
||||
expect($r->json('lead_statuses'))->toBeArray();
|
||||
expect(count($r->json('lead_statuses')))->toBeGreaterThanOrEqual(14);
|
||||
expect(count($r->json('lead_statuses')))->toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
test('GET /api/lead-statuses возвращает все 14 системных статусов из seed', function () {
|
||||
test('GET /api/lead-statuses возвращает все 5 системных статусов из seed', function () {
|
||||
$r = $this->getJson('/api/lead-statuses');
|
||||
|
||||
$slugs = collect($r->json('lead_statuses'))->pluck('slug')->all();
|
||||
$expected = [
|
||||
'new', 'viewed', 'worked', 'base', 'missed', 'negotiations',
|
||||
'waiting_payment', 'partnership', 'paid', 'closed',
|
||||
'test_drive', 'hot', 'replacement', 'final_missed',
|
||||
];
|
||||
$expected = ['new', 'viewed', 'in_progress', 'won', 'lost'];
|
||||
foreach ($expected as $slug) {
|
||||
expect($slugs)->toContain($slug);
|
||||
}
|
||||
|
||||
@@ -59,11 +59,12 @@ it('supplier_csv_reconcile_log table exists with required columns and status CHE
|
||||
]))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
it('schema.sql v8.21 has correct metrics — 63 base tables, 118 indexes, 40 RLS policies', function () {
|
||||
it('schema.sql v8.22 has correct metrics — 63 base tables, 119 indexes, 40 RLS policies', function () {
|
||||
// Замена destructive `migrate:fresh` (cross-test coupling: после DROP CASCADE остальные
|
||||
// Feature-тесты в той же сессии видели пустую БД). Static parse `db/schema.sql` —
|
||||
// источник истины метрик из spec §2.4 / db/CHANGELOG_schema.md v8.21.
|
||||
// источник истины метрик из spec §2.4 / db/CHANGELOG_schema.md v8.22.
|
||||
// v8.21 (Sprint 4): +1 таблица import_unknown_statuses, +1 индекс, +1 RLS-политика.
|
||||
// v8.22 (Plan 6/C9): +1 GIN-индекс idx_projects_regions.
|
||||
$schemaPath = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql';
|
||||
expect(is_file($schemaPath) && is_readable($schemaPath))->toBeTrue();
|
||||
$schema = file_get_contents($schemaPath);
|
||||
@@ -76,7 +77,7 @@ it('schema.sql v8.21 has correct metrics — 63 base tables, 118 indexes, 40 RLS
|
||||
expect($baseTables)->toBe(63);
|
||||
|
||||
$createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema);
|
||||
expect($createIndexes)->toBe(118);
|
||||
expect($createIndexes)->toBe(119); // v8.22 (Plan 6/C9): +1 GIN idx_projects_regions
|
||||
|
||||
$createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema);
|
||||
expect($createPolicies)->toBe(40);
|
||||
|
||||
@@ -19,8 +19,7 @@ it('creates a site project with valid payload', function () {
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'okna-spb.ru',
|
||||
'daily_limit_target' => 50,
|
||||
'region_mask' => 0,
|
||||
'region_mode' => 'include',
|
||||
'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
@@ -36,7 +35,7 @@ it('rejects invalid site domain', function () {
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/projects', [
|
||||
'name' => 'X', 'signal_type' => 'site', 'signal_identifier' => 'not a domain',
|
||||
'daily_limit_target' => 50, 'region_mask' => 0, 'region_mode' => 'include',
|
||||
'daily_limit_target' => 50, 'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
@@ -50,7 +49,7 @@ it('creates a call project with valid 11-digit phone', function () {
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/projects', [
|
||||
'name' => 'Натяжные', 'signal_type' => 'call', 'signal_identifier' => '79161234567',
|
||||
'daily_limit_target' => 30, 'region_mask' => 0, 'region_mode' => 'include',
|
||||
'daily_limit_target' => 30, 'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
@@ -63,7 +62,7 @@ it('rejects call signal_identifier not starting with 7', function () {
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/projects', [
|
||||
'name' => 'X', 'signal_type' => 'call', 'signal_identifier' => '89991234567',
|
||||
'daily_limit_target' => 30, 'region_mask' => 0, 'region_mode' => 'include',
|
||||
'daily_limit_target' => 30, 'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
@@ -77,7 +76,7 @@ it('creates sms project with senders + keyword', function () {
|
||||
$response = $this->actingAs($user)->postJson('/api/projects', [
|
||||
'name' => 'Ипотека', 'signal_type' => 'sms',
|
||||
'sms_senders' => ['TINKOFF'], 'sms_keyword' => 'ипотека',
|
||||
'daily_limit_target' => 100, 'region_mask' => 0, 'region_mode' => 'include',
|
||||
'daily_limit_target' => 100, 'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
@@ -93,7 +92,7 @@ it('rejects sms project without sms_senders', function () {
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/projects', [
|
||||
'name' => 'X', 'signal_type' => 'sms',
|
||||
'daily_limit_target' => 100, 'region_mask' => 0, 'region_mode' => 'include',
|
||||
'daily_limit_target' => 100, 'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
@@ -108,7 +107,7 @@ it('rejects when tenant exceeds max_projects limit', function () {
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/projects', [
|
||||
'name' => 'second', 'signal_type' => 'site', 'signal_identifier' => 'second.ru',
|
||||
'daily_limit_target' => 10, 'region_mask' => 0, 'region_mode' => 'include',
|
||||
'daily_limit_target' => 10, 'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
@@ -123,7 +122,7 @@ it('forces tenant_id from auth user (not from payload)', function () {
|
||||
$this->actingAs($userA)->postJson('/api/projects', [
|
||||
'tenant_id' => $tenantB->id, // попытка инъекции
|
||||
'name' => 'X', 'signal_type' => 'site', 'signal_identifier' => 'x.ru',
|
||||
'daily_limit_target' => 10, 'region_mask' => 0, 'region_mode' => 'include',
|
||||
'daily_limit_target' => 10, 'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
@@ -137,10 +136,67 @@ it('rejects site domain with consecutive dots', function () {
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/projects', [
|
||||
'name' => 'X', 'signal_type' => 'site', 'signal_identifier' => 'okna..spb.ru',
|
||||
'daily_limit_target' => 50, 'region_mask' => 0, 'region_mode' => 'include',
|
||||
'daily_limit_target' => 50, 'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['signal_identifier']);
|
||||
});
|
||||
|
||||
// Plan 6 — subject-level regions[] support.
|
||||
|
||||
it('creates project with subject-level regions array', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/projects', [
|
||||
'name' => 'Regions Test Project',
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'regions-test.example',
|
||||
'daily_limit_target' => 50,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [82, 83], // Москва + СПб
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonPath('data.regions', [82, 83]);
|
||||
$created = Project::where('name', 'Regions Test Project')->firstOrFail();
|
||||
expect($created->regions)->toBe([82, 83]);
|
||||
});
|
||||
|
||||
it('dual-writes region_mask=255 + region_mode=include for backward-compat', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/projects', [
|
||||
'name' => 'Dual Write Test',
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'dualwrite.example',
|
||||
'daily_limit_target' => 50,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [77],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$created = Project::where('name', 'Dual Write Test')->firstOrFail();
|
||||
expect($created->region_mask)->toBe(255);
|
||||
expect($created->region_mode)->toBe('include');
|
||||
});
|
||||
|
||||
it('rejects regions code out of 1..89 range with 422', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/projects', [
|
||||
'name' => 'Invalid Code Test',
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'invalid.example',
|
||||
'daily_limit_target' => 50,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [90, 100],
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['regions.0', 'regions.1']);
|
||||
});
|
||||
|
||||
@@ -78,14 +78,50 @@ it('cross-tenant update returns 404', function () {
|
||||
])->assertStatus(404);
|
||||
});
|
||||
|
||||
it('updates region_mask and delivery_days_mask', function () {
|
||||
it('updates delivery_days_mask (region_mask now read-only — see regions[] tests below)', function () {
|
||||
// Plan 6: region_mask/region_mode больше не клиент-controllable через UpdateProjectRequest
|
||||
// (validation rules удалены, ProjectService::create dual-writes 255/include).
|
||||
// Источник истины для региональной фильтрации — projects.regions INT[] (Plan 6).
|
||||
// Этот тест адаптирован: проверяет, что delivery_days_mask остаётся writeable через PATCH.
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$this->actingAs($user)->patchJson("/api/projects/{$project->id}", [
|
||||
'region_mask' => 78, 'region_mode' => 'exclude', 'delivery_days_mask' => 31,
|
||||
'delivery_days_mask' => 31,
|
||||
])->assertOk();
|
||||
|
||||
expect($project->fresh()->region_mask)->toBe(78);
|
||||
expect($project->fresh()->delivery_days_mask)->toBe(31);
|
||||
});
|
||||
|
||||
// Plan 6 — subject-level regions[] support.
|
||||
|
||||
it('updates regions array via PATCH', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id, 'regions' => []]);
|
||||
|
||||
$response = $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [
|
||||
'regions' => [82],
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('data.regions', [82]);
|
||||
expect($project->fresh()->regions)->toBe([82]);
|
||||
});
|
||||
|
||||
it('preserves regions when PATCH omits the field (sometimes rule)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'regions' => [82, 83],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [
|
||||
'name' => 'Renamed Project',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
expect($project->fresh()->regions)->toBe([82, 83]);
|
||||
});
|
||||
|
||||
@@ -55,12 +55,12 @@ test('slug = managers', function () {
|
||||
expect((new ManagersSummaryProvider)->slug())->toBe('managers');
|
||||
});
|
||||
|
||||
test('агрегирует сделки по менеджеру: total, paid, конверсия', function () {
|
||||
test('агрегирует сделки по менеджеру: total, won, конверсия', function () {
|
||||
$manager = User::factory()->create([
|
||||
'tenant_id' => $this->tenant->id, 'first_name' => 'Иван', 'last_name' => 'Петров',
|
||||
]);
|
||||
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'paid']);
|
||||
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'paid']);
|
||||
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'won']);
|
||||
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'won']);
|
||||
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'new']);
|
||||
|
||||
$rows = (new ManagersSummaryProvider)->rows(managersJob($this->tenant->id));
|
||||
|
||||
@@ -350,7 +350,7 @@ test('POST /api/reports/jobs (sync queue): managers_summary → done с CSV', fu
|
||||
'project_id' => $project->id,
|
||||
'manager_id' => $manager->id,
|
||||
'phone' => '+79990001122',
|
||||
'status' => 'paid',
|
||||
'status' => 'won',
|
||||
'received_at' => $now,
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
@@ -383,7 +383,7 @@ test('POST /api/reports/jobs (sync queue): sources_summary → done с CSV', fun
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_id' => $project->id,
|
||||
'phone' => '+79990002233',
|
||||
'status' => 'paid',
|
||||
'status' => 'won',
|
||||
'utm_source' => 'yandex',
|
||||
'received_at' => $now,
|
||||
'created_at' => Carbon::now(),
|
||||
|
||||
@@ -53,9 +53,9 @@ test('slug = sources', function () {
|
||||
});
|
||||
|
||||
test('агрегирует сделки по utm_source', function () {
|
||||
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'status' => 'paid']);
|
||||
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'status' => 'won']);
|
||||
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'status' => 'new']);
|
||||
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'vk', 'status' => 'paid']);
|
||||
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'vk', 'status' => 'won']);
|
||||
|
||||
$rows = (new SourcesSummaryProvider)->rows(sourcesJob($this->tenant->id));
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\SupplierProject;
|
||||
use App\Models\SupplierSyncLog;
|
||||
use App\Models\Tenant;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@@ -309,6 +310,36 @@ test('respects time budget by stopping at 20:55 МСК', function (): void {
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('passes regions directly to allocator without bitmask conversion', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'regions' => [82, 83],
|
||||
'region_mask' => 255,
|
||||
]);
|
||||
|
||||
$job = new SyncSupplierProjectsJob;
|
||||
$projects = Project::where('tenant_id', $tenant->id)->get();
|
||||
$adapted = (fn (Collection $p) => $this->adaptProjectsForAllocator($p))->call($job, $projects);
|
||||
|
||||
expect($adapted->first()->regions)->toBe([82, 83]);
|
||||
});
|
||||
|
||||
test('passes empty array to allocator when project has regions=[]', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'regions' => [],
|
||||
'region_mask' => 255,
|
||||
]);
|
||||
|
||||
$job = new SyncSupplierProjectsJob;
|
||||
$projects = Project::where('tenant_id', $tenant->id)->get();
|
||||
$adapted = (fn (Collection $p) => $this->adaptProjectsForAllocator($p))->call($job, $projects);
|
||||
|
||||
expect($adapted->first()->regions)->toBe([]);
|
||||
});
|
||||
|
||||
test('sticky auth error throws and sends critical alert email', function (): void {
|
||||
Mail::fake();
|
||||
Bus::fake([RefreshSupplierSessionJob::class]);
|
||||
@@ -346,3 +377,38 @@ test('sticky auth error throws and sends critical alert email', function (): voi
|
||||
return $mail->alertType === 'sticky_auth';
|
||||
});
|
||||
});
|
||||
|
||||
test('outbound: copies project regions[] into supplier_project current_regions via full handle()', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'regions-flow.example.com',
|
||||
'supplier_external_id' => null,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [],
|
||||
'current_regions' => [],
|
||||
]);
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'regions-flow.example.com',
|
||||
'supplier_b1_project_id' => $sp->id,
|
||||
'daily_limit_target' => 9,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [82, 83],
|
||||
'region_mask' => 255,
|
||||
'region_mode' => 'include',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/rt-project-save' => Http::response(['id' => 556], 200),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle();
|
||||
|
||||
$sp->refresh();
|
||||
expect($sp->current_regions)->toBe([82, 83])
|
||||
->and($sp->supplier_external_id)->toBe('556');
|
||||
});
|
||||
|
||||
@@ -105,7 +105,7 @@ function makeApiDetail(overrides: Partial<AdminTenantDetailResponse> = {}): Admi
|
||||
event: 'deal.status_changed',
|
||||
deal_id: 4470,
|
||||
actor_email: 'ivan@okna-moscow.ru',
|
||||
context: { from: 'viewed', to: 'worked' },
|
||||
context: { from: 'viewed', to: 'in_progress' },
|
||||
created_at: '2026-05-09T07:18:00+00:00',
|
||||
},
|
||||
],
|
||||
@@ -221,7 +221,7 @@ describe('AdminTenantDetailView.vue (API integration)', () => {
|
||||
expect(text).toContain('webhook.received');
|
||||
expect(text).toContain('deal.status_changed');
|
||||
expect(text).toContain('ivan@okna-moscow.ru'); // actor_email
|
||||
expect(text).toContain('viewed → worked'); // summary из context
|
||||
expect(text).toContain('viewed → in_progress'); // summary из context
|
||||
});
|
||||
|
||||
it('кнопка «Войти как клиент» open impersonationDialog', async () => {
|
||||
|
||||
@@ -87,14 +87,16 @@ describe('AppLayout.vue', () => {
|
||||
expect(text).toContain('Команда');
|
||||
});
|
||||
|
||||
it('содержит все 6 nav-пунктов (Менеджеры+Напоминания убраны по требованию заказчика)', async () => {
|
||||
it('содержит 6 nav-пунктов (Импорт данных + Отчёты убраны по требованию заказчика)', async () => {
|
||||
const wrapper = await mountAppLayout();
|
||||
const text = wrapper.text();
|
||||
['Проекты', 'Сделки', 'Канбан', 'Дашборд', 'Биллинг', 'Отчёты', 'Настройки'].forEach((label) =>
|
||||
['Проекты', 'Сделки', 'Канбан', 'Дашборд', 'Биллинг', 'Настройки'].forEach((label) =>
|
||||
expect(text).toContain(label),
|
||||
);
|
||||
expect(text).not.toContain('Менеджеры');
|
||||
expect(text).not.toContain('Напоминания');
|
||||
expect(text).not.toContain('Импорт данных');
|
||||
expect(text).not.toContain('Отчёты');
|
||||
});
|
||||
|
||||
it('показывает счётчики только у пунктов с count', async () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ function makeSummary(overrides: Partial<DashboardSummary> = {}): DashboardSummar
|
||||
active_projects: { active: 8, limit: 10 },
|
||||
balance: { amount_rub: '14250.00', runway_days: 4, runway_leads: 285 },
|
||||
activity: { points: [3, 5, 2, 8, 6, 9, 4], labels: ['сб', 'вс', 'пн', 'вт', 'ср', 'чт', 'сегодня'], max: 10 },
|
||||
funnel: { new: 18, paid: 45 },
|
||||
funnel: { new: 18, won: 45 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,95 +1,47 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import DealDetailDrawer from '../../resources/js/components/deals/DealDetailDrawer.vue';
|
||||
import { MOCK_DEALS } from '../../resources/js/composables/mockDeals';
|
||||
import type { MockDeal } from '../../resources/js/composables/mockDeals';
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
const vuetify = createVuetify();
|
||||
const deal: MockDeal = {
|
||||
id: 1, name: '+7 999', phone: '+7 999', statusSlug: 'new', project: 'Окна',
|
||||
manager: { initials: 'AD', name: 'Admin' }, cost: 0, receivedMinutesAgo: 5,
|
||||
};
|
||||
|
||||
// DealDetailDrawer использует v-navigation-drawer, который требует layout-
|
||||
// контекст от v-app/v-layout. В Vitest auto-import недоступен — stub'им
|
||||
// v-navigation-drawer как passthrough div чтобы slot-content рендерился
|
||||
// и был доступен для assertion.
|
||||
|
||||
describe('DealDetailDrawer.vue', () => {
|
||||
const factory = (props: { open: boolean; deal: (typeof MOCK_DEALS)[number] | null }) =>
|
||||
mount(DealDetailDrawer, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [createVuetify()],
|
||||
stubs: {
|
||||
VNavigationDrawer: {
|
||||
template: '<div class="drawer-stub" v-if="modelValue"><slot /></div>',
|
||||
props: ['modelValue'],
|
||||
},
|
||||
function mountDrawer(props: Record<string, unknown>) {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
return mount(DealDetailDrawer, {
|
||||
props: { open: true, deal, ...props },
|
||||
global: {
|
||||
plugins: [vuetify, pinia],
|
||||
stubs: {
|
||||
DealDetailBody: true,
|
||||
VNavigationDrawer: {
|
||||
template: '<div class="drawer-stub" v-if="modelValue"><slot /></div>',
|
||||
props: ['modelValue'],
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const sampleDeal = MOCK_DEALS[0]; // Анна Соколова
|
||||
|
||||
it('не рендерит контент когда open=false', () => {
|
||||
const wrapper = factory({ open: false, deal: sampleDeal });
|
||||
expect(wrapper.find('.drawer-stub').exists()).toBe(false);
|
||||
describe('DealDetailDrawer wrapper', () => {
|
||||
it('inline=true рендерит <aside> (master-detail панель)', () => {
|
||||
const w = mountDrawer({ inline: true });
|
||||
expect(w.find('aside.deal-detail-inline').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('не рендерит контент когда deal=null (даже при open=true)', () => {
|
||||
const wrapper = factory({ open: true, deal: null });
|
||||
// Drawer открыт, но deal нет — content внутри v-if не рендерится.
|
||||
const stub = wrapper.find('.drawer-stub');
|
||||
if (stub.exists()) {
|
||||
// Нет hero/section элементов внутри.
|
||||
expect(wrapper.find('.hero').exists()).toBe(false);
|
||||
}
|
||||
it('inline=false (по умолчанию) рендерит overlay v-navigation-drawer', () => {
|
||||
const w = mountDrawer({});
|
||||
expect(w.find('aside.deal-detail-inline').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('рендерит hero с именем сделки и id', () => {
|
||||
const wrapper = factory({ open: true, deal: sampleDeal });
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain(sampleDeal.name);
|
||||
expect(text).toContain(`#${sampleDeal.id}`);
|
||||
});
|
||||
|
||||
it('рендерит phone как кликабельную ссылку tel:', () => {
|
||||
const wrapper = factory({ open: true, deal: sampleDeal });
|
||||
const phoneLink = wrapper.find('.phone-link');
|
||||
expect(phoneLink.exists()).toBe(true);
|
||||
expect(phoneLink.attributes('href')).toMatch(/^tel:\+/);
|
||||
expect(phoneLink.text()).toBe(sampleDeal.phone);
|
||||
});
|
||||
|
||||
it('рендерит status-chip с nameRu статуса сделки', () => {
|
||||
const wrapper = factory({ open: true, deal: sampleDeal });
|
||||
// sampleDeal.statusSlug='new' → 'Новые'.
|
||||
expect(wrapper.text()).toContain('Новые');
|
||||
});
|
||||
|
||||
it('рендерит секцию параметров с проектом, стоимостью, менеджером', () => {
|
||||
const wrapper = factory({ open: true, deal: sampleDeal });
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Параметры');
|
||||
expect(text).toContain(sampleDeal.project);
|
||||
expect(text).toContain(sampleDeal.manager.name);
|
||||
expect(text).toMatch(/1\s+850\s*₽/); // sampleDeal.cost = 1850
|
||||
});
|
||||
|
||||
it('рендерит timeline без событий (без tenantId events пуст — I3)', () => {
|
||||
const wrapper = factory({ open: true, deal: sampleDeal });
|
||||
const items = wrapper.findAll('.timeline-item');
|
||||
expect(items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('emit-ит update:open=false при close-кнопке', async () => {
|
||||
const wrapper = factory({ open: true, deal: sampleDeal });
|
||||
// Vuetify v-btn рендерит как button. close-btn — единственный с aria-label.
|
||||
const closeBtn = wrapper.find('button[aria-label="Закрыть панель"]');
|
||||
if (closeBtn.exists()) {
|
||||
await closeBtn.trigger('click');
|
||||
expect(wrapper.emitted('update:open')).toBeTruthy();
|
||||
expect(wrapper.emitted('update:open')?.[0]).toEqual([false]);
|
||||
}
|
||||
it('inline-панель содержит DealDetailBody', () => {
|
||||
const w = mountDrawer({ inline: true });
|
||||
expect(w.findComponent({ name: 'DealDetailBody' }).exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import DealDetailDrawer from '../../resources/js/components/deals/DealDetailDrawer.vue';
|
||||
import DealDetailBody from '../../resources/js/components/deals/DealDetailBody.vue';
|
||||
import { MOCK_DEALS } from '../../resources/js/composables/mockDeals';
|
||||
import type { GetDealResponse, ApiDealEvent } from '../../resources/js/api/deals';
|
||||
|
||||
@@ -22,17 +22,11 @@ beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
const factory = (props: { open: boolean; tenantId?: number }) =>
|
||||
mount(DealDetailDrawer, {
|
||||
const factory = (props: { tenantId?: number }) =>
|
||||
mount(DealDetailBody, {
|
||||
props: { deal: MOCK_DEALS[0], ...props },
|
||||
global: {
|
||||
plugins: [createVuetify()],
|
||||
stubs: {
|
||||
VNavigationDrawer: {
|
||||
template: '<div class="drawer-stub" v-if="modelValue"><slot /></div>',
|
||||
props: ['modelValue'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -47,9 +41,9 @@ function makeApiEvent(overrides: Partial<ApiDealEvent> = {}): ApiDealEvent {
|
||||
};
|
||||
}
|
||||
|
||||
describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
|
||||
describe('DealDetailBody ↔ GET /api/deals/{id} integration', () => {
|
||||
it('БЕЗ tenantId — getDeal не вызывается, events пуст (I3)', async () => {
|
||||
const wrapper = factory({ open: true });
|
||||
const wrapper = factory({});
|
||||
await flushPromises();
|
||||
|
||||
expect(dealsApi.getDeal).not.toHaveBeenCalled();
|
||||
@@ -67,6 +61,9 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
|
||||
phone: '+7 (999) 100-00-01',
|
||||
contact_name: 'Anna',
|
||||
comment: null,
|
||||
city: null,
|
||||
project_signal_type: null,
|
||||
next_reminder_at: null,
|
||||
status: 'new',
|
||||
manager_id: null,
|
||||
manager_name: null,
|
||||
@@ -79,27 +76,27 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
|
||||
makeApiEvent({
|
||||
id: 2,
|
||||
event: 'deal.status_changed',
|
||||
context: { from: 'new', to: 'paid' },
|
||||
context: { from: 'new', to: 'won' },
|
||||
actor: { id: 1, name: 'Иван П.', initials: 'ИП' },
|
||||
}),
|
||||
],
|
||||
};
|
||||
vi.mocked(dealsApi.getDeal).mockResolvedValueOnce(apiResponse);
|
||||
|
||||
const wrapper = factory({ open: true, tenantId: 1 });
|
||||
const wrapper = factory({ tenantId: 1 });
|
||||
await flushPromises();
|
||||
|
||||
expect(dealsApi.getDeal).toHaveBeenCalledWith(MOCK_DEALS[0].id, 1);
|
||||
const items = wrapper.findAll('.timeline-item');
|
||||
expect(items).toHaveLength(2);
|
||||
// status_changed event имеет detail "new → paid".
|
||||
expect(wrapper.text()).toContain('new → paid');
|
||||
// status_changed event имеет detail "new → won".
|
||||
expect(wrapper.text()).toContain('new → won');
|
||||
});
|
||||
|
||||
it('getDeal reject → eventsFetchError=true, alert виден, events пуст (I3)', async () => {
|
||||
vi.mocked(dealsApi.getDeal).mockRejectedValueOnce(new Error('500'));
|
||||
|
||||
const wrapper = factory({ open: true, tenantId: 1 });
|
||||
const wrapper = factory({ tenantId: 1 });
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as { eventsFetchError: boolean };
|
||||
@@ -110,12 +107,6 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
|
||||
expect(items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('open=false → getDeal не вызывается', async () => {
|
||||
factory({ open: false, tenantId: 1 });
|
||||
await flushPromises();
|
||||
expect(dealsApi.getDeal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saveComment вызывает updateDeal + toast success + reload events', async () => {
|
||||
const apiResponse: GetDealResponse = {
|
||||
deal: {
|
||||
@@ -126,6 +117,9 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
|
||||
phone: '+7 (999) 100-00-01',
|
||||
contact_name: 'Anna',
|
||||
comment: 'old',
|
||||
city: null,
|
||||
project_signal_type: null,
|
||||
next_reminder_at: null,
|
||||
status: 'new',
|
||||
manager_id: null,
|
||||
manager_name: null,
|
||||
@@ -141,7 +135,7 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
|
||||
comment: 'новая заметка',
|
||||
});
|
||||
|
||||
const wrapper = factory({ open: true, tenantId: 1 });
|
||||
const wrapper = factory({ tenantId: 1 });
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
@@ -177,6 +171,9 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
|
||||
phone: '+7 (999)',
|
||||
contact_name: null,
|
||||
comment: null,
|
||||
city: null,
|
||||
project_signal_type: null,
|
||||
next_reminder_at: null,
|
||||
status: 'new',
|
||||
manager_id: null,
|
||||
manager_name: null,
|
||||
@@ -188,7 +185,7 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
|
||||
});
|
||||
vi.mocked(dealsApi.updateDeal).mockRejectedValueOnce(new Error('500'));
|
||||
|
||||
const wrapper = factory({ open: true, tenantId: 1 });
|
||||
const wrapper = factory({ tenantId: 1 });
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
@@ -206,7 +203,7 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
|
||||
});
|
||||
|
||||
it('comment-section не показывается без tenantId (read-only mode)', async () => {
|
||||
const wrapper = factory({ open: true });
|
||||
const wrapper = factory({});
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-testid="comment-section"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import DealsBulkBar from '../../resources/js/components/deals/DealsBulkBar.vue';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
const leadStatuses = [
|
||||
{ slug: 'new', nameRu: 'Новые', isSystem: true, sortOrder: 1, colorHex: '#3B82F6' },
|
||||
{ slug: 'viewed', nameRu: 'Просмотрено', isSystem: true, sortOrder: 2, colorHex: '#8B5CF6' },
|
||||
];
|
||||
|
||||
describe('DealsBulkBar', () => {
|
||||
it('скрыт при selectedCount=0', () => {
|
||||
const w = mount(DealsBulkBar, {
|
||||
props: { selectedCount: 0, statusMenuOpen: false, leadStatuses },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
expect(w.find('[data-testid="bulk-bar"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('виден при selectedCount>0 и показывает количество', () => {
|
||||
const w = mount(DealsBulkBar, {
|
||||
props: { selectedCount: 3, statusMenuOpen: false, leadStatuses },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
const bar = w.find('[data-testid="bulk-bar"]');
|
||||
expect(bar.exists()).toBe(true);
|
||||
expect(bar.text()).toContain('3');
|
||||
});
|
||||
|
||||
it('НЕ содержит кнопок Экспорт/Удалить', () => {
|
||||
const w = mount(DealsBulkBar, {
|
||||
props: { selectedCount: 2, statusMenuOpen: false, leadStatuses },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
expect(w.find('[data-testid="bulk-export-btn"]').exists()).toBe(false);
|
||||
expect(w.find('[data-testid="bulk-delete-btn"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('✕ эмитит clear-selected', async () => {
|
||||
const w = mount(DealsBulkBar, {
|
||||
props: { selectedCount: 2, statusMenuOpen: false, leadStatuses },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
await w.find('[data-testid="bulk-clear-btn"]').trigger('click');
|
||||
expect(w.emitted('clear-selected')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import DealsFilters from '../../resources/js/components/deals/DealsFilters.vue';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
const baseProps = {
|
||||
searchPhone: '',
|
||||
filterStatus: null,
|
||||
filterProject: null,
|
||||
filterCity: null,
|
||||
leadStatuses: [{ slug: 'new', nameRu: 'Новые', isSystem: true, sortOrder: 1, colorHex: '#000' }],
|
||||
availableProjects: [{ id: 1, name: 'Окна' }],
|
||||
availableCities: [] as string[],
|
||||
};
|
||||
|
||||
describe('DealsFilters', () => {
|
||||
it('рендерит поле поиска по телефону', () => {
|
||||
const w = mount(DealsFilters, { props: baseProps, global: { plugins: [vuetify] } });
|
||||
expect(w.find('[data-testid="filter-search-phone"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('эмитит update:searchPhone при вводе', async () => {
|
||||
const w = mount(DealsFilters, { props: baseProps, global: { plugins: [vuetify] } });
|
||||
await w.find('[data-testid="filter-search-phone"] input').setValue('999');
|
||||
expect(w.emitted('update:searchPhone')?.at(-1)).toEqual(['999']);
|
||||
});
|
||||
|
||||
it('город-селект disabled при пустом availableCities', () => {
|
||||
const w = mount(DealsFilters, { props: baseProps, global: { plugins: [vuetify] } });
|
||||
expect(w.find('[data-testid="filter-city"]').classes()).toContain('v-input--disabled');
|
||||
});
|
||||
|
||||
it('кнопка сброса видна когда есть активный фильтр', () => {
|
||||
const w = mount(DealsFilters, {
|
||||
props: { ...baseProps, filterStatus: 'new' },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
expect(w.find('[data-testid="clear-filters-btn"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@ import DealsView from '../../resources/js/views/DealsView.vue';
|
||||
import KanbanView from '../../resources/js/views/KanbanView.vue';
|
||||
import { useAuthStore } from '../../resources/js/stores/auth';
|
||||
import type { ApiDeal } from '../../resources/js/api/deals';
|
||||
import { MOCK_DEALS } from '../../resources/js/composables/mockDeals';
|
||||
|
||||
vi.mock('../../resources/js/api/deals', async (importOriginal) => {
|
||||
const orig = await importOriginal<typeof import('../../resources/js/api/deals')>();
|
||||
@@ -17,10 +16,6 @@ vi.mock('../../resources/js/api/deals', async (importOriginal) => {
|
||||
listManagers: vi.fn().mockResolvedValue([]),
|
||||
listProjects: vi.fn().mockResolvedValue([]),
|
||||
transitionDeals: vi.fn(),
|
||||
exportDeals: vi.fn(),
|
||||
exportDealsXlsx: vi.fn(),
|
||||
bulkDeleteDeals: vi.fn(),
|
||||
bulkRestoreDeals: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -39,6 +34,10 @@ function makeApiDeal(overrides: Partial<ApiDeal> = {}): ApiDeal {
|
||||
manager_name: 'Иван П.',
|
||||
manager_initials: 'ИП',
|
||||
received_at: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
||||
comment: null,
|
||||
city: null,
|
||||
project_signal_type: null,
|
||||
next_reminder_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -73,7 +72,7 @@ const mountDealsView = async () => {
|
||||
return mount(DealsView, {
|
||||
global: {
|
||||
plugins: [createVuetify(), router],
|
||||
stubs: { DealDetailDrawer: true, NewDealDialog: true },
|
||||
stubs: { DealDetailDrawer: true },
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -100,11 +99,11 @@ describe('DealsView ↔ GET /api/deals integration', () => {
|
||||
setupAuth(1);
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [
|
||||
makeApiDeal({ id: 200, contact_name: 'Из API #1', status: 'paid' }),
|
||||
makeApiDeal({ id: 200, contact_name: 'Из API #1', status: 'won' }),
|
||||
makeApiDeal({ id: 201, contact_name: 'Из API #2', status: 'new' }),
|
||||
],
|
||||
total: 2,
|
||||
limit: 200,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
@@ -112,7 +111,7 @@ describe('DealsView ↔ GET /api/deals integration', () => {
|
||||
await flushPromises();
|
||||
|
||||
expect(dealsApi.listDeals).toHaveBeenCalledTimes(1);
|
||||
expect(dealsApi.listDeals).toHaveBeenCalledWith(expect.objectContaining({ tenantId: 1, limit: 200 }));
|
||||
expect(dealsApi.listDeals).toHaveBeenCalledWith(expect.objectContaining({ tenantId: 1, limit: 20 }));
|
||||
|
||||
const vm = wrapper.vm as unknown as { dealsState: { id: number; name: string }[] };
|
||||
expect(vm.dealsState).toHaveLength(2);
|
||||
@@ -134,88 +133,12 @@ describe('DealsView ↔ GET /api/deals integration', () => {
|
||||
expect(wrapper.find('[data-testid="fetch-error-alert"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('toggleTrashMode переключает trashMode + listDeals вызывается с onlyDeleted=true', async () => {
|
||||
setupAuth(1);
|
||||
// Начальный fetch (нормальный режим)
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [makeApiDeal({ id: 600 })],
|
||||
total: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
// После toggle в trash
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [makeApiDeal({ id: 700, contact_name: 'Удалённый' })],
|
||||
total: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
const wrapper = await mountDealsView();
|
||||
await flushPromises();
|
||||
expect(dealsApi.listDeals).toHaveBeenCalledTimes(1);
|
||||
expect(dealsApi.listDeals).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ tenantId: 1, onlyDeleted: false }),
|
||||
);
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
trashMode: boolean;
|
||||
toggleTrashMode: () => void;
|
||||
dealsState: { id: number }[];
|
||||
};
|
||||
|
||||
vm.toggleTrashMode();
|
||||
await flushPromises();
|
||||
|
||||
expect(vm.trashMode).toBe(true);
|
||||
expect(dealsApi.listDeals).toHaveBeenCalledTimes(2);
|
||||
expect(dealsApi.listDeals).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ tenantId: 1, onlyDeleted: true }),
|
||||
);
|
||||
expect(vm.dealsState.find((d) => d.id === 700)).toBeDefined();
|
||||
});
|
||||
|
||||
it('applyBulkRestoreFromTrash восстанавливает + убирает из dealsState', async () => {
|
||||
setupAuth(1);
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [makeApiDeal({ id: 800 }), makeApiDeal({ id: 801 })],
|
||||
total: 2,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
vi.mocked(dealsApi.bulkRestoreDeals).mockResolvedValueOnce({
|
||||
restored: 2,
|
||||
requested: 2,
|
||||
});
|
||||
|
||||
const wrapper = await mountDealsView();
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkRestoreFromTrash: () => Promise<void>;
|
||||
dealsState: { id: number }[];
|
||||
deleteToastText: string;
|
||||
};
|
||||
|
||||
vm.selected = [800, 801];
|
||||
await flushPromises();
|
||||
await vm.applyBulkRestoreFromTrash();
|
||||
await flushPromises();
|
||||
|
||||
expect(dealsApi.bulkRestoreDeals).toHaveBeenCalledWith({ tenant_id: 1, ids: [800, 801] });
|
||||
// Восстановленные убраны из текущего trash-списка.
|
||||
expect(vm.dealsState.find((d) => d.id === 800)).toBeUndefined();
|
||||
expect(vm.dealsState.find((d) => d.id === 801)).toBeUndefined();
|
||||
expect(vm.deleteToastText).toContain('Восстановлено 2');
|
||||
});
|
||||
|
||||
it('reload-btn повторно вызывает listDeals', async () => {
|
||||
setupAuth(1);
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValue({
|
||||
deals: [makeApiDeal({ id: 400 })],
|
||||
total: 1,
|
||||
limit: 200,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
@@ -233,13 +156,13 @@ describe('DealsView ↔ GET /api/deals integration', () => {
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [makeApiDeal({ id: 500, status: 'new' }), makeApiDeal({ id: 501, status: 'new' })],
|
||||
total: 2,
|
||||
limit: 200,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
vi.mocked(dealsApi.transitionDeals).mockResolvedValueOnce({
|
||||
updated: 2,
|
||||
requested: 2,
|
||||
status: 'paid',
|
||||
status: 'won',
|
||||
});
|
||||
|
||||
const wrapper = await mountDealsView();
|
||||
@@ -255,353 +178,27 @@ describe('DealsView ↔ GET /api/deals integration', () => {
|
||||
vm.selected = [500, 501];
|
||||
await flushPromises();
|
||||
|
||||
await vm.applyBulkStatus('paid');
|
||||
await vm.applyBulkStatus('won');
|
||||
await flushPromises();
|
||||
|
||||
// Optimistic local-update применился до завершения API-вызова.
|
||||
expect(vm.dealsState.find((d) => d.id === 500)?.statusSlug).toBe('paid');
|
||||
expect(vm.dealsState.find((d) => d.id === 501)?.statusSlug).toBe('paid');
|
||||
expect(vm.dealsState.find((d) => d.id === 500)?.statusSlug).toBe('won');
|
||||
expect(vm.dealsState.find((d) => d.id === 501)?.statusSlug).toBe('won');
|
||||
expect(dealsApi.transitionDeals).toHaveBeenCalledWith({
|
||||
tenant_id: 1,
|
||||
ids: [500, 501],
|
||||
status: 'paid',
|
||||
status: 'won',
|
||||
});
|
||||
expect(vm.statusToastOpen).toBe(true);
|
||||
expect(vm.statusToastText).toContain('Обновлено 2');
|
||||
});
|
||||
|
||||
it('applyBulkStatus БЕЗ tenant_id — только локальный update, transitionDeals НЕ вызывается', async () => {
|
||||
setupAuth(null);
|
||||
const wrapper = await mountDealsView();
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkStatus: (slug: string) => Promise<void>;
|
||||
dealsState: { id: number; statusSlug: string }[];
|
||||
};
|
||||
// Засеваем dealsState mock-фикстурой (после I3 init пустой)
|
||||
vm.dealsState.push(...MOCK_DEALS.map((d) => ({ ...d, manager: { ...d.manager } })));
|
||||
await flushPromises();
|
||||
vm.selected = [1];
|
||||
await flushPromises();
|
||||
await vm.applyBulkStatus('paid');
|
||||
await flushPromises();
|
||||
|
||||
expect(dealsApi.transitionDeals).not.toHaveBeenCalled();
|
||||
expect(vm.dealsState.find((d) => d.id === 1)?.statusSlug).toBe('paid');
|
||||
});
|
||||
|
||||
it('applyBulkExport(xlsx) с tenant_id вызывает exportDealsXlsx и триггерит download', async () => {
|
||||
setupAuth(1);
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [makeApiDeal({ id: 700 })],
|
||||
total: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
const fakeBlob = new Blob(['fake xlsx'], { type: 'application/octet-stream' });
|
||||
vi.mocked(dealsApi.exportDealsXlsx).mockResolvedValueOnce(fakeBlob);
|
||||
|
||||
const createUrlSpy = vi.fn(() => 'blob:xlsx');
|
||||
const revokeSpy = vi.fn();
|
||||
Object.defineProperty(URL, 'createObjectURL', { value: createUrlSpy, configurable: true });
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { value: revokeSpy, configurable: true });
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
|
||||
const wrapper = await mountDealsView();
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkExport: (format?: string) => Promise<void>;
|
||||
exportToastText: string;
|
||||
};
|
||||
vm.selected = [700];
|
||||
await flushPromises();
|
||||
await vm.applyBulkExport(); // default = xlsx
|
||||
await flushPromises();
|
||||
|
||||
expect(dealsApi.exportDealsXlsx).toHaveBeenCalledWith({
|
||||
tenant_id: 1,
|
||||
ids: [700],
|
||||
});
|
||||
expect(dealsApi.exportDeals).not.toHaveBeenCalled();
|
||||
expect(createUrlSpy).toHaveBeenCalledTimes(1);
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1);
|
||||
expect(vm.exportToastText).toContain('XLSX');
|
||||
clickSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('applyBulkExport(csv) с tenant_id вызывает exportDeals (CSV branch)', async () => {
|
||||
setupAuth(1);
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [makeApiDeal({ id: 701 })],
|
||||
total: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
vi.mocked(dealsApi.exportDeals).mockResolvedValueOnce('id;...');
|
||||
|
||||
Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:'), configurable: true });
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
|
||||
const wrapper = await mountDealsView();
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkExport: (format?: string) => Promise<void>;
|
||||
exportToastText: string;
|
||||
};
|
||||
vm.selected = [701];
|
||||
await flushPromises();
|
||||
await vm.applyBulkExport('csv');
|
||||
await flushPromises();
|
||||
|
||||
expect(dealsApi.exportDeals).toHaveBeenCalledTimes(1);
|
||||
expect(dealsApi.exportDealsXlsx).not.toHaveBeenCalled();
|
||||
expect(vm.exportToastText).toContain('CSV');
|
||||
clickSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('applyBulkExport(xlsx) reject → fallback на local CSV', async () => {
|
||||
setupAuth(1);
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [makeApiDeal({ id: 702 })],
|
||||
total: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
vi.mocked(dealsApi.exportDealsXlsx).mockRejectedValueOnce(new Error('500'));
|
||||
|
||||
Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:'), configurable: true });
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
|
||||
const wrapper = await mountDealsView();
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkExport: () => Promise<void>;
|
||||
exportToastText: string;
|
||||
};
|
||||
vm.selected = [702];
|
||||
await flushPromises();
|
||||
await vm.applyBulkExport();
|
||||
await flushPromises();
|
||||
|
||||
expect(vm.exportToastText).toContain('Backend недоступен');
|
||||
// local CSV всё равно стриггерил download
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
clickSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('applyBulkDelete с tenant_id вызывает bulkDeleteDeals + optimistic local-removal', async () => {
|
||||
setupAuth(1);
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [makeApiDeal({ id: 800, status: 'new' }), makeApiDeal({ id: 801, status: 'new' })],
|
||||
total: 2,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
vi.mocked(dealsApi.bulkDeleteDeals).mockResolvedValueOnce({
|
||||
deleted: 2,
|
||||
requested: 2,
|
||||
});
|
||||
|
||||
const wrapper = await mountDealsView();
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkDelete: () => Promise<void>;
|
||||
dealsState: { id: number }[];
|
||||
deleteToastOpen: boolean;
|
||||
deleteToastText: string;
|
||||
};
|
||||
vm.selected = [800, 801];
|
||||
await flushPromises();
|
||||
|
||||
await vm.applyBulkDelete();
|
||||
await flushPromises();
|
||||
|
||||
// Optimistic — обе сделки убраны из state.
|
||||
expect(vm.dealsState.find((d) => d.id === 800)).toBeUndefined();
|
||||
expect(vm.dealsState.find((d) => d.id === 801)).toBeUndefined();
|
||||
expect(dealsApi.bulkDeleteDeals).toHaveBeenCalledWith({
|
||||
tenant_id: 1,
|
||||
ids: [800, 801],
|
||||
});
|
||||
expect(vm.deleteToastOpen).toBe(true);
|
||||
expect(vm.deleteToastText).toContain('Удалено 2');
|
||||
});
|
||||
|
||||
it('applyBulkDelete без tenant_id — только локально, bulkDeleteDeals НЕ вызывается', async () => {
|
||||
setupAuth(null);
|
||||
const wrapper = await mountDealsView();
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkDelete: () => Promise<void>;
|
||||
dealsState: { id: number }[];
|
||||
};
|
||||
// Засеваем dealsState mock-фикстурой (после I3 init пустой)
|
||||
vm.dealsState.push(...MOCK_DEALS.map((d) => ({ ...d, manager: { ...d.manager } })));
|
||||
await flushPromises();
|
||||
const before = vm.dealsState.length;
|
||||
vm.selected = [1, 2];
|
||||
await flushPromises();
|
||||
await vm.applyBulkDelete();
|
||||
await flushPromises();
|
||||
|
||||
expect(dealsApi.bulkDeleteDeals).not.toHaveBeenCalled();
|
||||
expect(vm.dealsState.length).toBe(before - 2);
|
||||
});
|
||||
|
||||
it('applyBulkDelete reject → warning toast, локальный update остаётся (не откатываем)', async () => {
|
||||
setupAuth(1);
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [makeApiDeal({ id: 900 })],
|
||||
total: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
vi.mocked(dealsApi.bulkDeleteDeals).mockRejectedValueOnce(new Error('500'));
|
||||
|
||||
const wrapper = await mountDealsView();
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkDelete: () => Promise<void>;
|
||||
dealsState: { id: number }[];
|
||||
deleteToastText: string;
|
||||
};
|
||||
vm.selected = [900];
|
||||
await flushPromises();
|
||||
await vm.applyBulkDelete();
|
||||
await flushPromises();
|
||||
|
||||
expect(vm.dealsState.find((d) => d.id === 900)).toBeUndefined(); // optimistic
|
||||
expect(vm.deleteToastText).toContain('Не удалось');
|
||||
});
|
||||
|
||||
it('bulk-delete + undo восстанавливает сделки + вызывает bulkRestoreDeals', async () => {
|
||||
setupAuth(1);
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [makeApiDeal({ id: 1000, contact_name: 'A' }), makeApiDeal({ id: 1001, contact_name: 'B' })],
|
||||
total: 2,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
vi.mocked(dealsApi.bulkDeleteDeals).mockResolvedValueOnce({
|
||||
deleted: 2,
|
||||
requested: 2,
|
||||
});
|
||||
vi.mocked(dealsApi.bulkRestoreDeals).mockResolvedValueOnce({
|
||||
restored: 2,
|
||||
requested: 2,
|
||||
});
|
||||
|
||||
const wrapper = await mountDealsView();
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkDelete: () => Promise<void>;
|
||||
undoBulkDelete: () => Promise<void>;
|
||||
dealsState: { id: number }[];
|
||||
lastDeletedSnapshot: { id: number }[];
|
||||
deleteToastText: string;
|
||||
};
|
||||
|
||||
// Удаляем
|
||||
vm.selected = [1000, 1001];
|
||||
await flushPromises();
|
||||
await vm.applyBulkDelete();
|
||||
await flushPromises();
|
||||
|
||||
expect(vm.dealsState.find((d) => d.id === 1000)).toBeUndefined();
|
||||
expect(vm.lastDeletedSnapshot).toHaveLength(2);
|
||||
|
||||
// Undo
|
||||
await vm.undoBulkDelete();
|
||||
await flushPromises();
|
||||
|
||||
expect(dealsApi.bulkRestoreDeals).toHaveBeenCalledWith({ tenant_id: 1, ids: [1000, 1001] });
|
||||
expect(vm.dealsState.find((d) => d.id === 1000)).toBeDefined();
|
||||
expect(vm.dealsState.find((d) => d.id === 1001)).toBeDefined();
|
||||
expect(vm.lastDeletedSnapshot).toHaveLength(0); // cleared after undo
|
||||
expect(vm.deleteToastText).toContain('Восстановлено 2');
|
||||
});
|
||||
|
||||
it('undoBulkDelete без tenant_id — только локально, bulkRestoreDeals НЕ вызывается', async () => {
|
||||
setupAuth(null);
|
||||
const wrapper = await mountDealsView();
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkDelete: () => Promise<void>;
|
||||
undoBulkDelete: () => Promise<void>;
|
||||
dealsState: { id: number }[];
|
||||
lastDeletedSnapshot: { id: number }[];
|
||||
};
|
||||
// Засеваем dealsState mock-фикстурой (после I3 init пустой)
|
||||
vm.dealsState.push(...MOCK_DEALS.map((d) => ({ ...d, manager: { ...d.manager } })));
|
||||
await flushPromises();
|
||||
|
||||
const sample = vm.dealsState[0];
|
||||
vm.selected = [sample.id];
|
||||
await flushPromises();
|
||||
await vm.applyBulkDelete();
|
||||
await flushPromises();
|
||||
expect(vm.dealsState.find((d) => d.id === sample.id)).toBeUndefined();
|
||||
|
||||
await vm.undoBulkDelete();
|
||||
await flushPromises();
|
||||
|
||||
expect(dealsApi.bulkRestoreDeals).not.toHaveBeenCalled();
|
||||
expect(vm.dealsState.find((d) => d.id === sample.id)).toBeDefined();
|
||||
});
|
||||
|
||||
it('undoBulkDelete reject → warning toast, локальное восстановление остаётся', async () => {
|
||||
setupAuth(1);
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [makeApiDeal({ id: 1100 })],
|
||||
total: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
vi.mocked(dealsApi.bulkDeleteDeals).mockResolvedValueOnce({ deleted: 1, requested: 1 });
|
||||
vi.mocked(dealsApi.bulkRestoreDeals).mockRejectedValueOnce(new Error('500'));
|
||||
|
||||
const wrapper = await mountDealsView();
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkDelete: () => Promise<void>;
|
||||
undoBulkDelete: () => Promise<void>;
|
||||
dealsState: { id: number }[];
|
||||
deleteToastText: string;
|
||||
};
|
||||
vm.selected = [1100];
|
||||
await flushPromises();
|
||||
await vm.applyBulkDelete();
|
||||
await flushPromises();
|
||||
await vm.undoBulkDelete();
|
||||
await flushPromises();
|
||||
|
||||
expect(vm.dealsState.find((d) => d.id === 1100)).toBeDefined(); // optimistic
|
||||
expect(vm.deleteToastText).toContain('Не удалось восстановить');
|
||||
});
|
||||
|
||||
it('applyBulkStatus с reject → toast с warning, локальный update остаётся (не откатываем)', async () => {
|
||||
setupAuth(1);
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [makeApiDeal({ id: 600, status: 'new' })],
|
||||
total: 1,
|
||||
limit: 200,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
vi.mocked(dealsApi.transitionDeals).mockRejectedValueOnce(new Error('500'));
|
||||
@@ -617,10 +214,10 @@ describe('DealsView ↔ GET /api/deals integration', () => {
|
||||
};
|
||||
vm.selected = [600];
|
||||
await flushPromises();
|
||||
await vm.applyBulkStatus('paid');
|
||||
await vm.applyBulkStatus('won');
|
||||
await flushPromises();
|
||||
|
||||
expect(vm.dealsState.find((d) => d.id === 600)?.statusSlug).toBe('paid');
|
||||
expect(vm.dealsState.find((d) => d.id === 600)?.statusSlug).toBe('won');
|
||||
expect(vm.statusToastText).toContain('Не удалось');
|
||||
});
|
||||
});
|
||||
@@ -638,8 +235,8 @@ describe('KanbanView ↔ GET /api/deals integration', () => {
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [
|
||||
makeApiDeal({ id: 300, status: 'new' }),
|
||||
makeApiDeal({ id: 301, status: 'paid' }),
|
||||
makeApiDeal({ id: 302, status: 'paid' }),
|
||||
makeApiDeal({ id: 301, status: 'won' }),
|
||||
makeApiDeal({ id: 302, status: 'won' }),
|
||||
],
|
||||
total: 3,
|
||||
limit: 500,
|
||||
@@ -655,7 +252,7 @@ describe('KanbanView ↔ GET /api/deals integration', () => {
|
||||
fetchError: boolean;
|
||||
};
|
||||
expect(vm.dealsByStatus.new.map((d) => d.id)).toEqual([300]);
|
||||
expect(vm.dealsByStatus.paid.map((d) => d.id).sort()).toEqual([301, 302]);
|
||||
expect(vm.dealsByStatus.won.map((d) => d.id).sort()).toEqual([301, 302]);
|
||||
expect(vm.totalDeals).toBe(3);
|
||||
expect(vm.fetchError).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
|
||||
import DealsTable from '../../resources/js/components/deals/DealsTable.vue';
|
||||
import type { MockDeal } from '../../resources/js/composables/mockDeals';
|
||||
|
||||
@@ -9,51 +8,55 @@ const vuetify = createVuetify();
|
||||
|
||||
const sampleDeals: MockDeal[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Иванов И.',
|
||||
phone: '+7 (916) 100-00-01',
|
||||
statusSlug: 'new',
|
||||
project: 'B1 site',
|
||||
manager: { initials: 'AD', name: 'Admin' },
|
||||
cost: 1000,
|
||||
receivedMinutesAgo: 5,
|
||||
id: 1, name: '+7 (916) 100-00-01', phone: '+7 (916) 100-00-01', statusSlug: 'new',
|
||||
project: 'Окна', manager: { initials: 'AD', name: 'Admin' }, cost: 0, receivedMinutesAgo: 5,
|
||||
signalType: 'call', city: 'Москва', comment: 'звонил', receivedAt: '2026-05-15T09:00:00+00:00',
|
||||
nextReminderAt: '2026-05-18T07:00:00+00:00',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Петров П.',
|
||||
phone: '+7 (916) 100-00-02',
|
||||
statusSlug: 'new',
|
||||
project: 'B1 call',
|
||||
manager: { initials: 'AD', name: 'Admin' },
|
||||
cost: 1500,
|
||||
receivedMinutesAgo: 30,
|
||||
id: 2, name: '+7 (916) 100-00-02', phone: '+7 (916) 100-00-02', statusSlug: 'new',
|
||||
project: 'Двери', manager: { initials: 'AD', name: 'Admin' }, cost: 0, receivedMinutesAgo: 30,
|
||||
signalType: 'site', city: null, comment: null, receivedAt: '2026-05-14T09:00:00+00:00',
|
||||
nextReminderAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
describe('DealsTable a11y (Q.DEFER.004 sub-A)', () => {
|
||||
it('select-all header checkbox has aria-label', () => {
|
||||
const wrapper = mount(DealsTable, {
|
||||
describe('DealsTable', () => {
|
||||
it('рендерит колонки реестра лидов', () => {
|
||||
const w = mount(DealsTable, {
|
||||
props: { deals: sampleDeals, selectedIds: [], statusBySlug: new Map() },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
const headerCheckbox = wrapper.find(
|
||||
'th .v-selection-control input[type="checkbox"][aria-label="Выбрать все сделки"]',
|
||||
);
|
||||
expect(headerCheckbox.exists()).toBe(true);
|
||||
const headers = w.findAll('thead th').map((h) => h.text());
|
||||
['Телефон', 'Источник', 'Город', 'Статус', 'Напоминание', 'Комментарий', 'Поставлен'].forEach((label) => {
|
||||
expect(headers.some((h) => h.includes(label))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('each row checkbox has aria-label referencing deal name', () => {
|
||||
const wrapper = mount(DealsTable, {
|
||||
it('город без значения рендерится как «—»', () => {
|
||||
const w = mount(DealsTable, {
|
||||
props: { deals: sampleDeals, selectedIds: [], statusBySlug: new Map() },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
const rowCheckbox1 = wrapper.find(
|
||||
'tbody tr:nth-of-type(1) input[type="checkbox"][aria-label="Выбрать сделку «Иванов И.»"]',
|
||||
);
|
||||
const rowCheckbox2 = wrapper.find(
|
||||
'tbody tr:nth-of-type(2) input[type="checkbox"][aria-label="Выбрать сделку «Петров П.»"]',
|
||||
);
|
||||
expect(rowCheckbox1.exists()).toBe(true);
|
||||
expect(rowCheckbox2.exists()).toBe(true);
|
||||
expect(w.text()).toContain('—');
|
||||
});
|
||||
|
||||
it('select-all чекбокс имеет aria-label', () => {
|
||||
const w = mount(DealsTable, {
|
||||
props: { deals: sampleDeals, selectedIds: [], statusBySlug: new Map() },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
expect(
|
||||
w.find('th .v-selection-control input[type="checkbox"][aria-label="Выбрать все сделки"]').exists(),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('клик по строке эмитит row-click с deal', async () => {
|
||||
const w = mount(DealsTable, {
|
||||
props: { deals: sampleDeals, selectedIds: [], statusBySlug: new Map() },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
await w.find('tbody tr').trigger('click');
|
||||
expect(w.emitted('row-click')?.[0]?.[0]).toMatchObject({ id: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,447 +1,155 @@
|
||||
import { describe, it, test, expect, vi, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import DealsView from '../../resources/js/views/DealsView.vue';
|
||||
import { MOCK_DEALS, type MockDeal } from '../../resources/js/composables/mockDeals';
|
||||
import * as dealsApi from '../../resources/js/api/deals';
|
||||
import { useAuthStore } from '../../resources/js/stores/auth';
|
||||
import type { AuthUser } from '../../resources/js/api/auth';
|
||||
import type { MockDeal } from '../../resources/js/composables/mockDeals';
|
||||
|
||||
// Smoke-тесты DealsView с mock-данными.
|
||||
|
||||
/** Засевает dealsState фикстурой MOCK_DEALS (имитирует успешный API-ответ). */
|
||||
function seedDealsState(wrapper: ReturnType<typeof mount>) {
|
||||
const vm = wrapper.vm as unknown as { dealsState: MockDeal[] };
|
||||
vm.dealsState.push(...MOCK_DEALS.map((d) => ({ ...d, manager: { ...d.manager } })));
|
||||
function apiDeal(id: number, over: Partial<dealsApi.ApiDeal> = {}): dealsApi.ApiDeal {
|
||||
return {
|
||||
id, tenant_id: 42, project_id: 1, project_name: 'Окна', phone: `+7 916 000-00-0${id}`,
|
||||
contact_name: null, status: 'new', manager_id: null, manager_name: null,
|
||||
manager_initials: null, received_at: '2026-05-15T09:00:00+00:00',
|
||||
comment: null, city: null, project_signal_type: 'call', next_reminder_at: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const mountDeals = async () => {
|
||||
async function mountDeals(deals: dealsApi.ApiDeal[] = [apiDeal(1), apiDeal(2)], total = 2) {
|
||||
setActivePinia(createPinia());
|
||||
const auth = useAuthStore();
|
||||
auth.user = { id: 1, tenant_id: 42, email: 't@t.com' } as AuthUser;
|
||||
const dealsSpy = vi.spyOn(dealsApi, 'listDeals').mockResolvedValue({ deals, total, limit: 20, offset: 0 });
|
||||
vi.spyOn(dealsApi, 'listProjects').mockResolvedValue([
|
||||
{ id: 1, name: 'Окна', tag: null, type: 'supplier' },
|
||||
]);
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/deals', component: DealsView }],
|
||||
});
|
||||
await router.push('/deals');
|
||||
await router.isReady();
|
||||
// DealsView содержит DealDetailDrawer (v-navigation-drawer), который требует
|
||||
// injected layout от v-app — оборачиваем компонент в v-app для теста.
|
||||
// DealsView содержит DealDetailDrawer (v-navigation-drawer), который требует
|
||||
// layout-injection от v-app. В Vitest vite-plugin-vuetify auto-import не
|
||||
// работает, layout-context недоступен. Stub'им сам Drawer (тестируется
|
||||
// отдельно в DealDetailDrawer.spec.ts).
|
||||
const wrapper = mount(DealsView, {
|
||||
global: {
|
||||
plugins: [createVuetify(), router],
|
||||
stubs: { DealDetailDrawer: true, NewDealDialog: true },
|
||||
},
|
||||
global: { plugins: [createVuetify(), router], stubs: { DealDetailDrawer: true, DealsFilters: true } },
|
||||
});
|
||||
await flushPromises();
|
||||
seedDealsState(wrapper);
|
||||
await flushPromises();
|
||||
// Reset call history so subsequent vi.spyOn calls in tests start from count=0.
|
||||
dealsSpy.mockClear();
|
||||
return wrapper;
|
||||
};
|
||||
}
|
||||
|
||||
/** Audit C8/F3: монтирует DealsView по произвольному пути (с query-параметрами). */
|
||||
const mountDealsViewAt = async (path: string) => {
|
||||
setActivePinia(createPinia());
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/deals', component: DealsView }],
|
||||
});
|
||||
await router.push(path);
|
||||
await router.isReady();
|
||||
const wrapper = mount(DealsView, {
|
||||
global: {
|
||||
plugins: [createVuetify(), router],
|
||||
stubs: { DealDetailDrawer: true, NewDealDialog: true },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
seedDealsState(wrapper);
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
describe('DealsView.vue', () => {
|
||||
it('монтируется и содержит заголовок «Сделки»', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
expect(wrapper.find('h1').text()).toBe('Сделки');
|
||||
describe('DealsView.vue — реестр лидов', () => {
|
||||
it('заголовок «Сделки»', async () => {
|
||||
expect((await mountDeals()).find('h1').text()).toBe('Сделки');
|
||||
});
|
||||
|
||||
it('содержит page-stats с числами всего/в работе/ждут оплату', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('новых лида с утра');
|
||||
expect(text).toContain('всего');
|
||||
expect(text).toContain('в работе');
|
||||
expect(text).toContain('ждут оплату');
|
||||
it('панель экспорта: поля дат + кнопки Excel/CSV', async () => {
|
||||
const w = await mountDeals();
|
||||
expect(w.find('[data-testid="export-from"]').exists()).toBe(true);
|
||||
expect(w.find('[data-testid="export-to"]').exists()).toBe(true);
|
||||
expect(w.find('[data-testid="export-xlsx-btn"]').exists()).toBe(true);
|
||||
expect(w.find('[data-testid="export-csv-btn"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('содержит ровно 5 chiprow-tabs', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const text = wrapper.text();
|
||||
['Все', 'Активные', 'Ждут оплату', 'Закрытые', 'Невалидные'].forEach((label) => expect(text).toContain(label));
|
||||
it('селектор «Показывать по» с вариантами 10/20/50', async () => {
|
||||
const w = await mountDeals();
|
||||
const toggle = w.find('[data-testid="perpage-toggle"]');
|
||||
expect(toggle.exists()).toBe(true);
|
||||
['10', '20', '50'].forEach((n) => expect(toggle.text()).toContain(n));
|
||||
});
|
||||
|
||||
it('по умолчанию активен таб «Активные», показывает только active-сделки', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
await flushPromises();
|
||||
const activeStatuses = ['new', 'viewed', 'worked', 'negotiations', 'hot'];
|
||||
const expectedCount = MOCK_DEALS.filter((d) => activeStatuses.includes(d.statusSlug)).length;
|
||||
const rows = wrapper.findAll('tbody tr');
|
||||
expect(rows).toHaveLength(expectedCount);
|
||||
it('НЕТ кнопки «Новая сделка» и режима «Корзина»', async () => {
|
||||
const w = await mountDeals();
|
||||
// «Новая сделка» присутствует как статус-пилюля в таблице (slug `new` —
|
||||
// редизайн воронки 2026-05-17), поэтому проверяем отсутствие именно
|
||||
// КНОПКИ создания сделки: ручное создание убрано в реестре лидов.
|
||||
const buttons = w.findAll('button');
|
||||
expect(buttons.some((b) => b.text().includes('Новая сделка'))).toBe(false);
|
||||
expect(w.text()).not.toContain('Корзина');
|
||||
});
|
||||
|
||||
it('содержит кнопки Экспорт и Новая сделка', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Экспорт');
|
||||
expect(text).toContain('Новая сделка');
|
||||
it('загружает сделки в dealsState через API', async () => {
|
||||
const w = await mountDeals([apiDeal(1), apiDeal(2), apiDeal(3)], 3);
|
||||
const vm = w.vm as unknown as { dealsState: MockDeal[]; total: number };
|
||||
expect(vm.dealsState.length).toBe(3);
|
||||
expect(vm.total).toBe(3);
|
||||
});
|
||||
|
||||
it('таблица содержит колонки Лид/Статус/Проект/Менеджер/Стоимость/Время', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const headers = wrapper.findAll('thead th').map((h) => h.text());
|
||||
['Лид', 'Статус', 'Проект', 'Менеджер', 'Стоимость', 'Время'].forEach((label) => {
|
||||
expect(headers.some((h) => h.includes(label))).toBe(true);
|
||||
});
|
||||
it('openPanel выбирает сделку, повторный клик закрывает', async () => {
|
||||
const w = await mountDeals();
|
||||
const vm = w.vm as unknown as {
|
||||
dealsState: MockDeal[]; panelOpen: boolean; selectedDeal: MockDeal | null;
|
||||
openPanel: (d: MockDeal) => void;
|
||||
};
|
||||
vm.openPanel(vm.dealsState[0]);
|
||||
expect(vm.panelOpen).toBe(true);
|
||||
expect(vm.selectedDeal?.id).toBe(1);
|
||||
vm.openPanel(vm.dealsState[0]);
|
||||
expect(vm.panelOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('форматирует стоимость как «N ₽» с разделителем тысяч', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const text = wrapper.text();
|
||||
// Intl.NumberFormat('ru-RU') использует non-breaking space (U+00A0) или
|
||||
// narrow nbsp (U+202F) как разделитель тысяч, не ASCII-пробел. Явные
|
||||
// \u-escape'ы — иначе ESLint ругается no-irregular-whitespace.
|
||||
expect(text).toMatch(/2\s+400\s*₽/);
|
||||
});
|
||||
|
||||
it('форматирует «время с момента» как «N мин назад» для свежих сделок', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('7 мин назад');
|
||||
});
|
||||
|
||||
it('bulk-bar скрыт когда selected пустой; виден когда selected не пустой', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
await flushPromises();
|
||||
// По умолчанию ничего не выбрано
|
||||
expect(wrapper.find('[data-testid="bulk-bar"]').exists()).toBe(false);
|
||||
// Симулируем выбор через v-model: selected
|
||||
const vm = wrapper.vm as unknown as { selected: number[] };
|
||||
it('bulk-bar появляется при выборе и applyBulkStatus меняет статус', async () => {
|
||||
const w = await mountDeals();
|
||||
const vm = w.vm as unknown as {
|
||||
selected: number[]; dealsState: MockDeal[]; applyBulkStatus: (s: string) => Promise<void>;
|
||||
};
|
||||
vi.spyOn(dealsApi, 'transitionDeals').mockResolvedValue({ updated: 2, requested: 2, status: 'viewed' });
|
||||
vm.selected = [1, 2];
|
||||
await flushPromises();
|
||||
const bar = wrapper.find('[data-testid="bulk-bar"]');
|
||||
expect(bar.exists()).toBe(true);
|
||||
expect(bar.text()).toContain('Выбрано');
|
||||
expect(bar.text()).toContain('2');
|
||||
expect(w.find('[data-testid="bulk-bar"]').exists()).toBe(true);
|
||||
await vm.applyBulkStatus('viewed');
|
||||
expect(vm.dealsState.find((d) => d.id === 1)?.statusSlug).toBe('viewed');
|
||||
});
|
||||
|
||||
it('bulk-status: применение нового статуса меняет statusSlug у выбранных сделок и закрывает меню', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
dealsState: Array<{ id: number; statusSlug: string }>;
|
||||
applyBulkStatus: (slug: string) => void;
|
||||
};
|
||||
vm.selected = [1, 2];
|
||||
await flushPromises();
|
||||
// До применения — id=1 'new', id=2 'worked' (из MOCK_DEALS)
|
||||
const before1 = vm.dealsState.find((d) => d.id === 1)!.statusSlug;
|
||||
expect(before1).toBe('new');
|
||||
vm.applyBulkStatus('paid');
|
||||
await flushPromises();
|
||||
expect(vm.dealsState.find((d) => d.id === 1)!.statusSlug).toBe('paid');
|
||||
expect(vm.dealsState.find((d) => d.id === 2)!.statusSlug).toBe('paid');
|
||||
});
|
||||
|
||||
it('bulk-delete: confirm удаляет выбранные сделки и сбрасывает selected', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
dealsState: Array<{ id: number }>;
|
||||
applyBulkDelete: () => void;
|
||||
};
|
||||
const before = vm.dealsState.length;
|
||||
vm.selected = [1, 3];
|
||||
await flushPromises();
|
||||
vm.applyBulkDelete();
|
||||
await flushPromises();
|
||||
expect(vm.dealsState.length).toBe(before - 2);
|
||||
expect(vm.dealsState.find((d) => d.id === 1)).toBeUndefined();
|
||||
expect(vm.dealsState.find((d) => d.id === 3)).toBeUndefined();
|
||||
expect(vm.selected).toEqual([]);
|
||||
});
|
||||
|
||||
it('bulk-export: показывает toast с количеством выбранных сделок + триггерит CSV-download', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkExport: () => void;
|
||||
exportToastOpen: boolean;
|
||||
exportToastText: string;
|
||||
};
|
||||
// Шпион на createObjectURL — в jsdom он бывает не определён, заменим.
|
||||
const createUrlSpy = vi.fn(() => 'blob:mock');
|
||||
const revokeUrlSpy = vi.fn();
|
||||
Object.defineProperty(URL, 'createObjectURL', { value: createUrlSpy, configurable: true });
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { value: revokeUrlSpy, configurable: true });
|
||||
// Подменяем click() на якоре чтобы не словить navigation в jsdom.
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
|
||||
vm.selected = [1, 2, 3, 4];
|
||||
await flushPromises();
|
||||
vm.applyBulkExport();
|
||||
|
||||
expect(createUrlSpy).toHaveBeenCalledTimes(1);
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1);
|
||||
it('exportByRange xlsx вызывает exportDealsByRange', async () => {
|
||||
const w = await mountDeals();
|
||||
const spy = vi.spyOn(dealsApi, 'exportDealsByRange').mockResolvedValue(new Blob());
|
||||
Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:m'), configurable: true });
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), configurable: true });
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
const vm = w.vm as unknown as { exportByRange: (f: string) => Promise<void>; exportToastOpen: boolean };
|
||||
await vm.exportByRange('xlsx');
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
expect(vm.exportToastOpen).toBe(true);
|
||||
expect(vm.exportToastText).toContain('4');
|
||||
expect(vm.exportToastText).toContain('CSV');
|
||||
|
||||
clickSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('bulk-export: пустой selected → toast «Нет выбранных» без CSV', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkExport: () => void;
|
||||
exportToastOpen: boolean;
|
||||
exportToastText: string;
|
||||
};
|
||||
const createUrlSpy = vi.fn(() => 'blob:mock');
|
||||
Object.defineProperty(URL, 'createObjectURL', { value: createUrlSpy, configurable: true });
|
||||
vm.selected = [];
|
||||
vm.applyBulkExport();
|
||||
expect(createUrlSpy).not.toHaveBeenCalled();
|
||||
expect(vm.exportToastText).toContain('Нет выбранных');
|
||||
it('смена фильтра вызывает loadDeals ровно один раз (без двойного fetch)', async () => {
|
||||
const w = await mountDeals();
|
||||
const vm = w.vm as unknown as { filterStatus: string | null; page: number };
|
||||
// Установим spy до смены page, чтобы перехватить все вызовы
|
||||
const spy = vi.spyOn(dealsApi, 'listDeals').mockResolvedValue({ deals: [], total: 0, limit: 20, offset: 0 });
|
||||
// Переходим на страницу 3 — это вызовет watch(page) → loadDeals один раз
|
||||
vm.page = 3;
|
||||
await flushPromises();
|
||||
spy.mockClear(); // сбрасываем счётчик: интересует только смена фильтра
|
||||
// Смена фильтра при page=3: A10 fix должен лишь сбросить page→1 (без прямого loadDeals),
|
||||
// затем watch(page) делает ровно один fetch
|
||||
vm.filterStatus = 'viewed';
|
||||
await flushPromises();
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('кнопка «Новая сделка» открывает NewDealDialog (newDealOpen=true)', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as { newDealOpen: boolean };
|
||||
expect(vm.newDealOpen).toBe(false);
|
||||
await wrapper.find('[data-testid="new-deal-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
expect(vm.newDealOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('onDealCreated добавляет новую сделку в начало dealsState', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const vm = wrapper.vm as unknown as {
|
||||
dealsState: Array<{ id: number; name: string; statusSlug: string }>;
|
||||
onDealCreated: (deal: Record<string, unknown>) => void;
|
||||
};
|
||||
const before = vm.dealsState.length;
|
||||
// Передаём полную форму deal — table-cell ожидает manager.name/phone/cost.
|
||||
vm.onDealCreated({
|
||||
id: 999,
|
||||
name: 'Новый клиент',
|
||||
phone: '+7 (999) 000-00-00',
|
||||
statusSlug: 'new',
|
||||
project: 'Окна Москва',
|
||||
manager: { initials: 'Н', name: 'Новый М.' },
|
||||
cost: 1000,
|
||||
receivedMinutesAgo: 0,
|
||||
});
|
||||
await flushPromises();
|
||||
expect(vm.dealsState.length).toBe(before + 1);
|
||||
expect(vm.dealsState[0].id).toBe(999);
|
||||
expect(vm.dealsState[0].name).toBe('Новый клиент');
|
||||
});
|
||||
|
||||
it('smart-filter projects: оставляет только сделки выбранного проекта', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const vm = wrapper.vm as unknown as { activeTab: string; filterProjects: string[] };
|
||||
vm.activeTab = 'all';
|
||||
vm.filterProjects = ['Окна Москва'];
|
||||
await flushPromises();
|
||||
const rows = wrapper.findAll('tbody tr');
|
||||
// Минимум одна строка, и все содержат «Окна Москва»
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
rows.forEach((row) => expect(row.text()).toContain('Окна Москва'));
|
||||
});
|
||||
|
||||
it('smart-filter managers: оставляет только сделки выбранного менеджера', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const vm = wrapper.vm as unknown as { activeTab: string; filterManagers: string[] };
|
||||
vm.activeTab = 'all';
|
||||
vm.filterManagers = ['Иван П.'];
|
||||
await flushPromises();
|
||||
const rows = wrapper.findAll('tbody tr');
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
rows.forEach((row) => expect(row.text()).toContain('Иван П.'));
|
||||
});
|
||||
|
||||
it('clearFilters сбрасывает projects+managers фильтры, кнопка появляется по условию', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const vm = wrapper.vm as unknown as {
|
||||
filterProjects: string[];
|
||||
filterManagers: string[];
|
||||
clearFilters: () => void;
|
||||
};
|
||||
expect(wrapper.find('[data-testid="clear-filters-btn"]').exists()).toBe(false);
|
||||
vm.filterProjects = ['Окна Москва'];
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-testid="clear-filters-btn"]').exists()).toBe(true);
|
||||
vm.clearFilters();
|
||||
await flushPromises();
|
||||
expect(vm.filterProjects).toEqual([]);
|
||||
expect(vm.filterManagers).toEqual([]);
|
||||
});
|
||||
|
||||
it('bulk-clear: иконка ✕ сбрасывает selected', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const vm = wrapper.vm as unknown as { selected: number[] };
|
||||
vm.selected = [1, 2];
|
||||
await flushPromises();
|
||||
const clearBtn = wrapper.find('[data-testid="bulk-clear-btn"]');
|
||||
expect(clearBtn.exists()).toBe(true);
|
||||
await clearBtn.trigger('click');
|
||||
await flushPromises();
|
||||
expect(vm.selected).toEqual([]);
|
||||
});
|
||||
|
||||
// Audit C8/F3: deep-link /deals?openId=
|
||||
it('route.query.openId открывает drawer соответствующей сделки', async () => {
|
||||
const openId = MOCK_DEALS[0].id;
|
||||
// Мокаем API чтобы loadDeals заполнил state до вызова openDealFromQuery в onMounted.
|
||||
vi.spyOn(dealsApi, 'listDeals').mockResolvedValue({
|
||||
deals: MOCK_DEALS.map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
phone: d.phone,
|
||||
status: d.statusSlug,
|
||||
project_name: d.project,
|
||||
manager_name: d.manager.name,
|
||||
cost: d.cost,
|
||||
created_at: new Date(Date.now() - d.receivedMinutesAgo * 60000).toISOString(),
|
||||
deleted_at: null,
|
||||
})),
|
||||
total: MOCK_DEALS.length,
|
||||
} as never);
|
||||
it('loadDeals reject → dealsState пустой + fetchError', async () => {
|
||||
setActivePinia(createPinia());
|
||||
const auth = useAuthStore();
|
||||
auth.user = { id: 1, tenant_id: 42, email: 'test@test.com' } as AuthUser;
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/deals', component: DealsView }],
|
||||
});
|
||||
await router.push(`/deals?openId=${openId}`);
|
||||
auth.user = { id: 1, tenant_id: 42, email: 't@t.com' } as AuthUser;
|
||||
vi.spyOn(dealsApi, 'listDeals').mockRejectedValue(new Error('500'));
|
||||
vi.spyOn(dealsApi, 'listProjects').mockResolvedValue([]);
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/deals', component: DealsView }] });
|
||||
await router.push('/deals');
|
||||
await router.isReady();
|
||||
const wrapper = mount(DealsView, {
|
||||
global: {
|
||||
plugins: [createVuetify(), router],
|
||||
stubs: { DealDetailDrawer: true, NewDealDialog: true },
|
||||
},
|
||||
const w = mount(DealsView, {
|
||||
global: { plugins: [createVuetify(), router], stubs: { DealDetailDrawer: true, DealsFilters: true } },
|
||||
});
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as { drawerOpen: boolean; selectedDeal: { id: number } | null };
|
||||
expect(vm.drawerOpen).toBe(true);
|
||||
expect(vm.selectedDeal?.id).toBe(openId);
|
||||
const vm = w.vm as unknown as { dealsState: MockDeal[]; fetchError: boolean };
|
||||
expect(vm.dealsState.length).toBe(0);
|
||||
expect(vm.fetchError).toBe(true);
|
||||
});
|
||||
|
||||
it('openId не найден среди сделок — drawer не открывается, без ошибки', async () => {
|
||||
const wrapper = await mountDealsViewAt('/deals?openId=99999999');
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as { drawerOpen: boolean };
|
||||
expect(vm.drawerOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('навигация на /deals?openId= в смонтированном view открывает drawer (watch)', async () => {
|
||||
const openId = MOCK_DEALS[0].id;
|
||||
const wrapper = await mountDealsViewAt('/deals');
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as { drawerOpen: boolean; selectedDeal: { id: number } | null };
|
||||
expect(vm.drawerOpen).toBe(false);
|
||||
await wrapper.vm.$router.push(`/deals?openId=${openId}`);
|
||||
await flushPromises();
|
||||
expect(vm.drawerOpen).toBe(true);
|
||||
expect(vm.selectedDeal?.id).toBe(openId);
|
||||
});
|
||||
});
|
||||
|
||||
test('C3: exportAllFiltered вызывает backend-экспорт со всеми отфильтрованными id', async () => {
|
||||
const xlsxSpy = vi.spyOn(dealsApi, 'exportDealsXlsx').mockResolvedValue(new Blob());
|
||||
const wrapper = await mountDeals();
|
||||
await flushPromises();
|
||||
|
||||
// Установить auth.user с tenant_id чтобы exportDealIds пошёл в backend
|
||||
const auth = useAuthStore();
|
||||
auth.user = { id: 1, tenant_id: 42, email: 'test@test.com' } as AuthUser;
|
||||
|
||||
// activeTab по умолчанию 'active' — установить 'all' чтобы filteredDeals === dealsState
|
||||
const vm = wrapper.vm as unknown as {
|
||||
activeTab: string;
|
||||
dealsState: Array<{ id: number }>;
|
||||
exportAllFiltered: () => Promise<void>;
|
||||
exportToastOpen: boolean;
|
||||
};
|
||||
vm.activeTab = 'all';
|
||||
await flushPromises();
|
||||
|
||||
await vm.exportAllFiltered();
|
||||
|
||||
expect(xlsxSpy).toHaveBeenCalledTimes(1);
|
||||
const callArg = xlsxSpy.mock.calls[0][0];
|
||||
expect(callArg.ids).toEqual(vm.dealsState.map((d) => d.id));
|
||||
expect(vm.exportToastOpen).toBe(true);
|
||||
});
|
||||
|
||||
test('C3: exportAllFiltered на пустом списке показывает toast и не зовёт backend', async () => {
|
||||
const xlsxSpy = vi.spyOn(dealsApi, 'exportDealsXlsx').mockResolvedValue(new Blob());
|
||||
const wrapper = await mountDeals();
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
activeTab: string;
|
||||
dealsState: Array<{ id: number }>;
|
||||
exportAllFiltered: () => Promise<void>;
|
||||
exportToastOpen: boolean;
|
||||
exportToastText: string;
|
||||
};
|
||||
// Очистить список и поставить tab='all' чтобы filteredDeals тоже пустой
|
||||
vm.activeTab = 'all';
|
||||
vm.dealsState.splice(0, vm.dealsState.length);
|
||||
await flushPromises();
|
||||
|
||||
await vm.exportAllFiltered();
|
||||
|
||||
expect(xlsxSpy).not.toHaveBeenCalled();
|
||||
expect(vm.exportToastText).toBe('Список пуст — нечего экспортировать.');
|
||||
});
|
||||
|
||||
// I3 regression: API reject → dealsState пустой + fetchError=true (нет mock-fallback)
|
||||
// Faithful-паттерн: auth + mock ДО mount, onMounted сам вызывает loadDeals.
|
||||
test('I3: loadDeals reject оставляет dealsState пустым и выставляет fetchError', async () => {
|
||||
vi.spyOn(dealsApi, 'listDeals').mockRejectedValue(new Error('500'));
|
||||
setActivePinia(createPinia());
|
||||
const auth = useAuthStore();
|
||||
auth.user = { id: 1, tenant_id: 42, email: 'test@test.com' } as AuthUser;
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/deals', component: DealsView }],
|
||||
});
|
||||
await router.push('/deals');
|
||||
await router.isReady();
|
||||
const wrapper = mount(DealsView, {
|
||||
global: {
|
||||
plugins: [createVuetify(), router],
|
||||
stubs: { DealDetailDrawer: true, NewDealDialog: true },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
dealsState: MockDeal[];
|
||||
fetchError: boolean;
|
||||
};
|
||||
expect(vm.dealsState.length).toBe(0);
|
||||
expect(vm.fetchError).toBe(true);
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createMemoryHistory, createRouter } from 'vue-router';
|
||||
import DealsView from '../../resources/js/views/DealsView.vue';
|
||||
|
||||
|
||||
function setup() {
|
||||
setActivePinia(createPinia());
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/deals', component: DealsView }] });
|
||||
router.push('/deals');
|
||||
return mount(DealsView, {
|
||||
global: {
|
||||
plugins: [router, createVuetify()],
|
||||
stubs: { RouterLink: true, VDataTable: true },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('DealsView — redesigned', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it('renders filterbar with at least 3 FilterChips', async () => {
|
||||
const w = setup();
|
||||
await flushPromises();
|
||||
const chips = w.findAll('.ld-filter-chip');
|
||||
expect(chips.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('renders DensityToggle in filterbar', async () => {
|
||||
const w = setup();
|
||||
await flushPromises();
|
||||
expect(w.find('.ld-density-toggle').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('row uses StatusPill component for status column', async () => {
|
||||
const w = setup();
|
||||
await flushPromises();
|
||||
// After data load — at least one ld-status-pill should be present
|
||||
// (если stub VDataTable — test проверяет наличие компонента в template, не в render)
|
||||
expect(w.html()).toMatch(/ld-status-pill|StatusPill/);
|
||||
});
|
||||
|
||||
it('applies ld-hover-lift utility class to table container or row wrapper', async () => {
|
||||
const w = setup();
|
||||
await flushPromises();
|
||||
expect(w.html()).toMatch(/ld-hover-lift|hover-lift/);
|
||||
});
|
||||
|
||||
it('applies ld-stagger-row class to deal rows (motion #2)', async () => {
|
||||
const w = setup();
|
||||
await flushPromises();
|
||||
expect(w.html()).toMatch(/ld-stagger-row/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FilterChip popovers (Sprint 1 C2)', () => {
|
||||
function setupWithRouter() {
|
||||
setActivePinia(createPinia());
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/deals', component: DealsView }] });
|
||||
router.push('/deals');
|
||||
return mount(DealsView, {
|
||||
global: {
|
||||
plugins: [router, createVuetify()],
|
||||
stubs: { DealDetailDrawer: true, NewDealDialog: true, VMenu: { template: '<div><slot name="activator" :props="{}" /><slot /></div>' } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it('clicking Project chip toggles projectMenuOpen ref to true', async () => {
|
||||
const wrapper = setupWithRouter();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
expect(vm.projectMenuOpen).toBe(false);
|
||||
// Trigger via direct ref assignment (v-menu activator manages this ref).
|
||||
// watch(projectMenuOpen) will seed projectMenuDraft on open=true.
|
||||
vm.projectMenuOpen = true;
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(vm.projectMenuOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('clicking Manager chip toggles managerMenuOpen ref to true', async () => {
|
||||
const wrapper = setupWithRouter();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
expect(vm.managerMenuOpen).toBe(false);
|
||||
// Trigger via direct ref assignment (v-menu activator manages this ref).
|
||||
// watch(managerMenuOpen) will seed managerMenuDraft on open=true.
|
||||
vm.managerMenuOpen = true;
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(vm.managerMenuOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('applying project selection updates filterProjects and closes menu', async () => {
|
||||
const wrapper = setupWithRouter();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
// Open menu (watch seeds draft from filterProjects), then override draft manually.
|
||||
vm.projectMenuOpen = true;
|
||||
await wrapper.vm.$nextTick();
|
||||
vm.projectMenuDraft = ['demo-project-1', 'demo-project-2'];
|
||||
vm.applyProjectFilter();
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(vm.filterProjects).toEqual(['demo-project-1', 'demo-project-2']);
|
||||
expect(vm.projectMenuOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -16,22 +16,22 @@ describe('FunnelChart.vue', () => {
|
||||
expect(wrapper.text()).toContain('Воронка');
|
||||
});
|
||||
|
||||
it('содержит ровно 14 сегментов в bar (по числу lead_statuses)', () => {
|
||||
it('содержит ровно 5 сегментов в bar (по числу lead_statuses)', () => {
|
||||
const wrapper = factory();
|
||||
const segs = wrapper.findAll('.funnel-seg');
|
||||
expect(segs).toHaveLength(14);
|
||||
expect(segs).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('содержит ровно 14 list-items', () => {
|
||||
it('содержит ровно 5 list-items', () => {
|
||||
const wrapper = factory();
|
||||
const items = wrapper.findAll('.funnel-list-item');
|
||||
expect(items).toHaveLength(14);
|
||||
expect(items).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('использует правильные slug-имена из schema (НЕ из BRANDBOOK)', () => {
|
||||
const wrapper = factory();
|
||||
const text = wrapper.text();
|
||||
// Проверка что все 14 имён из lead_statuses присутствуют.
|
||||
// Проверка что все 5 имён из lead_statuses присутствуют.
|
||||
LEAD_STATUSES.forEach((s) => {
|
||||
expect(text).toContain(s.nameRu);
|
||||
});
|
||||
@@ -41,10 +41,10 @@ describe('FunnelChart.vue', () => {
|
||||
expect(text).not.toContain('Спам');
|
||||
});
|
||||
|
||||
it('сортирует список по убыванию count (paid 45 — первый)', () => {
|
||||
it('сортирует список по убыванию count (in_progress 96 — первый)', () => {
|
||||
const wrapper = factory();
|
||||
const names = wrapper.findAll('.funnel-list-item .name').map((n) => n.text());
|
||||
expect(names[0]).toBe('Оплачено'); // count=45 — самый большой в DEFAULT_COUNTS.
|
||||
expect(names[0]).toBe('В работе'); // count=96 — самый большой в DEFAULT_COUNTS.
|
||||
});
|
||||
|
||||
it('применяет colorHex из lead_statuses к dots и сегментам', () => {
|
||||
@@ -55,7 +55,7 @@ describe('FunnelChart.vue', () => {
|
||||
});
|
||||
|
||||
it('считает total как сумму counts', () => {
|
||||
const wrapper = factory({ counts: { new: 10, paid: 20 } });
|
||||
const wrapper = factory({ counts: { new: 10, won: 20 } });
|
||||
const text = wrapper.text();
|
||||
// total = 10 + 20 = 30 (остальные слаги с counts={} → 0).
|
||||
expect(text).toContain('30 лидов');
|
||||
|
||||
@@ -28,11 +28,11 @@ describe('KanbanView.vue', () => {
|
||||
expect(wrapper.find('h1').text()).toBe('Канбан');
|
||||
});
|
||||
|
||||
it('рендерит ровно 14 KanbanColumn (по числу lead_statuses)', () => {
|
||||
it('рендерит ровно 5 KanbanColumn (по числу lead_statuses)', () => {
|
||||
const wrapper = factory();
|
||||
const cols = wrapper.findAllComponents({ name: 'KanbanColumn' });
|
||||
expect(cols).toHaveLength(LEAD_STATUSES.length);
|
||||
expect(cols).toHaveLength(14);
|
||||
expect(cols).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('каждая колонка получает соответствующий статус', () => {
|
||||
@@ -46,7 +46,7 @@ describe('KanbanView.vue', () => {
|
||||
it('содержит page-stats с числом статусов и сделок', () => {
|
||||
const wrapper = factory();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('14');
|
||||
expect(text).toContain('5');
|
||||
expect(text).toContain('статусов');
|
||||
expect(text).toContain('сделок');
|
||||
});
|
||||
@@ -110,17 +110,17 @@ describe('KanbanView.vue', () => {
|
||||
const cols = wrapper.findAllComponents({ name: 'KanbanColumn' });
|
||||
// Берём сделку из первой колонки (new) и эмулируем «added» в paid-колонке.
|
||||
const newCol = cols[0]; // new — sortOrder=1
|
||||
const paidCol = cols.find((c) => c.props('status').slug === 'paid')!;
|
||||
const wonCol = cols.find((c) => c.props('status').slug === 'won')!;
|
||||
const dealToMove = (newCol.props('deals') as { id: number; statusSlug: string }[])[0];
|
||||
|
||||
// Эмуляция события vuedraggable@change → KanbanView.onColumnChange.
|
||||
await paidCol.vm.$emit('change', {
|
||||
await wonCol.vm.$emit('change', {
|
||||
added: { element: dealToMove, newIndex: 0 },
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// statusSlug сделки должен переключиться на 'paid'.
|
||||
expect(dealToMove.statusSlug).toBe('paid');
|
||||
// statusSlug сделки должен переключиться на 'won'.
|
||||
expect(dealToMove.statusSlug).toBe('won');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,7 +160,7 @@ describe('KanbanView DnD persist (Sprint 1 C4)', () => {
|
||||
const transitionSpy = vi.spyOn(dealsApi, 'transitionDeals').mockResolvedValue({
|
||||
updated: 1,
|
||||
requested: 1,
|
||||
status: 'hot',
|
||||
status: 'in_progress',
|
||||
});
|
||||
const wrapper = mount(KanbanView, {
|
||||
global: {
|
||||
@@ -174,14 +174,14 @@ describe('KanbanView DnD persist (Sprint 1 C4)', () => {
|
||||
|
||||
const deal = { id: 42, statusSlug: 'new' as const, name: 'X', phone: '+79161234567', project: 'p', manager: { name: 'M', initials: 'M' }, cost: 100, receivedMinutesAgo: 5 };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (wrapper.vm as any).onColumnChange('hot', { added: { element: deal, newIndex: 0 } });
|
||||
await (wrapper.vm as any).onColumnChange('in_progress', { added: { element: deal, newIndex: 0 } });
|
||||
|
||||
expect(transitionSpy).toHaveBeenCalledWith({
|
||||
tenant_id: 7,
|
||||
ids: [42],
|
||||
status: 'hot',
|
||||
status: 'in_progress',
|
||||
});
|
||||
expect(deal.statusSlug).toBe('hot');
|
||||
expect(deal.statusSlug).toBe('in_progress');
|
||||
});
|
||||
|
||||
it('onColumnChange reverts statusSlug + opens toast when API rejects', async () => {
|
||||
@@ -200,15 +200,15 @@ describe('KanbanView DnD persist (Sprint 1 C4)', () => {
|
||||
// Имитируем vuedraggable mutation: карточка уже в target column до вызова onColumnChange.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
if (!vm.dealsByStatus.hot) vm.dealsByStatus.hot = [];
|
||||
vm.dealsByStatus.hot.push(deal);
|
||||
if (!vm.dealsByStatus.in_progress) vm.dealsByStatus.in_progress = [];
|
||||
vm.dealsByStatus.in_progress.push(deal);
|
||||
|
||||
await vm.onColumnChange('hot', { added: { element: deal, newIndex: 0 } });
|
||||
await vm.onColumnChange('in_progress', { added: { element: deal, newIndex: 0 } });
|
||||
|
||||
// statusSlug rolled back
|
||||
expect(deal.statusSlug).toBe('new');
|
||||
// Card removed from target column (array-revert branch coverage)
|
||||
expect(vm.dealsByStatus.hot.findIndex((d: { id: number }) => d.id === 43)).toBe(-1);
|
||||
expect(vm.dealsByStatus.in_progress.findIndex((d: { id: number }) => d.id === 43)).toBe(-1);
|
||||
// Card restored to source column
|
||||
expect(vm.dealsByStatus.new.findIndex((d: { id: number }) => d.id === 43)).toBeGreaterThanOrEqual(0);
|
||||
// Toast shown
|
||||
@@ -218,7 +218,7 @@ describe('KanbanView DnD persist (Sprint 1 C4)', () => {
|
||||
|
||||
it('onColumnChange skips API call if no auth.user.tenant_id', async () => {
|
||||
const transitionSpy = vi.spyOn(dealsApi, 'transitionDeals').mockResolvedValue({
|
||||
updated: 1, requested: 1, status: 'hot',
|
||||
updated: 1, requested: 1, status: 'in_progress',
|
||||
});
|
||||
const wrapper = mount(KanbanView, {
|
||||
global: {
|
||||
@@ -232,10 +232,10 @@ describe('KanbanView DnD persist (Sprint 1 C4)', () => {
|
||||
|
||||
const deal = { id: 44, statusSlug: 'new' as const, name: 'Z', phone: '+79161234567', project: 'p', manager: { name: 'M', initials: 'M' }, cost: 100, receivedMinutesAgo: 5 };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (wrapper.vm as any).onColumnChange('hot', { added: { element: deal, newIndex: 0 } });
|
||||
await (wrapper.vm as any).onColumnChange('in_progress', { added: { element: deal, newIndex: 0 } });
|
||||
|
||||
// Без auth — только optimistic local change, API не зовётся
|
||||
expect(transitionSpy).not.toHaveBeenCalled();
|
||||
expect(deal.statusSlug).toBe('hot');
|
||||
expect(deal.statusSlug).toBe('in_progress');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,10 +125,10 @@ describe('NewDealDialog.vue', () => {
|
||||
});
|
||||
|
||||
it('presetStatus → statusSlug дефолтит на пресет (для KanbanView)', async () => {
|
||||
const wrapper = factory({ modelValue: true, presetStatus: 'paid' });
|
||||
const wrapper = factory({ modelValue: true, presetStatus: 'won' });
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as { statusSlug: string };
|
||||
expect(vm.statusSlug).toBe('paid');
|
||||
expect(vm.statusSlug).toBe('won');
|
||||
});
|
||||
|
||||
it('без tenantId — submit НЕ вызывает API (local-only mode)', async () => {
|
||||
|
||||
@@ -6,6 +6,16 @@ import axios from 'axios';
|
||||
|
||||
vi.mock('axios');
|
||||
|
||||
vi.mock('../../resources/js/api/client', () => ({
|
||||
apiClient: {
|
||||
post: vi.fn().mockResolvedValue({ data: {} }),
|
||||
patch: vi.fn().mockResolvedValue({ data: {} }),
|
||||
},
|
||||
ensureCsrfCookie: vi.fn().mockResolvedValue(undefined),
|
||||
extractErrorMessage: vi.fn(() => 'Произошла ошибка.'),
|
||||
}));
|
||||
|
||||
import { apiClient } from '../../resources/js/api/client';
|
||||
import NewProjectDialog from '../../resources/js/views/projects/NewProjectDialog.vue';
|
||||
import type { Project } from '../../resources/js/stores/projectsStore';
|
||||
|
||||
@@ -74,4 +84,24 @@ describe('NewProjectDialog', () => {
|
||||
it.skip('emits saved event after successful POST', async () => {
|
||||
// TODO: см. предыдущий skip — те же причины.
|
||||
});
|
||||
|
||||
it('renders regions autocomplete with 89 selectable subjects (excluding "Вся РФ" sentinel)', async () => {
|
||||
const wrapper = factory();
|
||||
await flushPromises();
|
||||
const autocomplete = wrapper.findComponent({ name: 'VAutocomplete' });
|
||||
expect(autocomplete.exists()).toBe(true);
|
||||
expect(autocomplete.props('items')).toHaveLength(89);
|
||||
expect((autocomplete.props('items') as Array<{ code: number }>).every((r) => r.code !== 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('sends regions array in POST payload', async () => {
|
||||
const wrapper = factory();
|
||||
await flushPromises();
|
||||
const autocomplete = wrapper.findComponent({ name: 'VAutocomplete' });
|
||||
autocomplete.vm.$emit('update:model-value', [82, 83]);
|
||||
await flushPromises();
|
||||
await wrapper.find('[data-testid="submit-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/api/projects', expect.objectContaining({ regions: [82, 83] }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ const sampleProject: Project = {
|
||||
archived_at: null,
|
||||
region_mask: 0,
|
||||
region_mode: 'include',
|
||||
regions: [],
|
||||
delivery_days_mask: 31, // Mon-Fri
|
||||
sync_status: 'pending',
|
||||
};
|
||||
@@ -50,7 +51,7 @@ describe('ProjectDetailsDrawer', () => {
|
||||
// Days mask 31 = bits 0..4 = Mon..Fri (5 days active)
|
||||
const dayBtns = wrapper.findAll('button[data-testid^="pdd-day-"]');
|
||||
expect(dayBtns.length).toBe(7);
|
||||
const activeBtns = dayBtns.filter(b => b.classes().includes('active'));
|
||||
const activeBtns = dayBtns.filter((b) => b.classes().includes('active'));
|
||||
expect(activeBtns.length).toBe(5);
|
||||
});
|
||||
|
||||
@@ -126,8 +127,12 @@ describe('ProjectDetailsDrawer', () => {
|
||||
});
|
||||
|
||||
it('Pause button calls store.toggleActive', async () => {
|
||||
(axios.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ data: { data: { ...sampleProject, is_active: false } } });
|
||||
(axios.get as unknown as ReturnType<typeof vi.fn> | undefined)?.mockResolvedValue?.({ data: { data: [], meta: { total: 0 } } });
|
||||
(axios.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
data: { data: { ...sampleProject, is_active: false } },
|
||||
});
|
||||
(axios.get as unknown as ReturnType<typeof vi.fn> | undefined)?.mockResolvedValue?.({
|
||||
data: { data: [], meta: { total: 0 } },
|
||||
});
|
||||
const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } });
|
||||
const store = useProjectsStore();
|
||||
const spy = vi.spyOn(store, 'toggleActive').mockResolvedValueOnce(undefined);
|
||||
@@ -174,33 +179,30 @@ describe('ProjectDetailsDrawer', () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('renders region chips when project has non-zero region_mask', async () => {
|
||||
const withRegions: Project = { ...sampleProject, region_mask: 6, region_mode: 'exclude' };
|
||||
it('renders region chips for project.regions = [1, 2]', async () => {
|
||||
const withRegions: Project = { ...sampleProject, regions: [1, 2] };
|
||||
const wrapper = mount(ProjectDetailsDrawer, { props: { project: withRegions } });
|
||||
await wrapper.vm.$nextTick();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Адыгея');
|
||||
expect(text).toContain('Башкортостан');
|
||||
expect(text).toContain('Адыгея'); // code 1
|
||||
expect(text).toContain('Алтай'); // code 2 (Республика Алтай)
|
||||
});
|
||||
|
||||
it('selecting regions encodes mask + sets mode=exclude on save', async () => {
|
||||
it('selecting regions adds to regions array (no bitmask conversion)', async () => {
|
||||
(axios.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ data: { data: sampleProject } });
|
||||
const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } });
|
||||
const autocomplete = wrapper.getComponent({ name: 'VAutocomplete' });
|
||||
await autocomplete.vm.$emit('update:model-value', [3, 5]);
|
||||
await autocomplete.vm.$emit('update:model-value', [82, 83]);
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.get('[data-testid="pdd-save"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(axios.patch).toHaveBeenCalledWith(
|
||||
'/api/projects/42',
|
||||
expect.objectContaining({ region_mask: 40, region_mode: 'exclude' }),
|
||||
);
|
||||
expect(axios.patch).toHaveBeenCalledWith('/api/projects/42', expect.objectContaining({ regions: [82, 83] }));
|
||||
});
|
||||
|
||||
it('clearing all regions resets mask=0 + mode=include on save', async () => {
|
||||
it('clearing all regions sets regions=[] on save', async () => {
|
||||
(axios.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ data: { data: sampleProject } });
|
||||
const withRegions: Project = { ...sampleProject, region_mask: 6, region_mode: 'exclude' };
|
||||
const withRegions: Project = { ...sampleProject, regions: [82, 83] };
|
||||
const wrapper = mount(ProjectDetailsDrawer, { props: { project: withRegions } });
|
||||
const autocomplete = wrapper.getComponent({ name: 'VAutocomplete' });
|
||||
await autocomplete.vm.$emit('update:model-value', []);
|
||||
@@ -208,9 +210,6 @@ describe('ProjectDetailsDrawer', () => {
|
||||
await wrapper.get('[data-testid="pdd-save"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(axios.patch).toHaveBeenCalledWith(
|
||||
'/api/projects/42',
|
||||
expect.objectContaining({ region_mask: 0, region_mode: 'include' }),
|
||||
);
|
||||
expect(axios.patch).toHaveBeenCalledWith('/api/projects/42', expect.objectContaining({ regions: [] }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,15 +15,18 @@ const mountDialog = (count = 5) =>
|
||||
},
|
||||
});
|
||||
|
||||
interface DialogVm {
|
||||
addRegions: number[];
|
||||
removeRegions: number[];
|
||||
}
|
||||
|
||||
describe('RegionsBulkDialog', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()));
|
||||
|
||||
it('renders 8 federal-district chips for Add and Remove', () => {
|
||||
it('renders subject-level Add and Remove selectors (not federal districts)', () => {
|
||||
const wrapper = mountDialog();
|
||||
const addChips = wrapper.findAll('[data-testid^="region-add-"]');
|
||||
const removeChips = wrapper.findAll('[data-testid^="region-remove-"]');
|
||||
expect(addChips.length).toBe(8);
|
||||
expect(removeChips.length).toBe(8);
|
||||
expect(wrapper.find('[data-testid="region-add-select"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="region-remove-select"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('shows count from prop', () => {
|
||||
@@ -31,20 +34,25 @@ describe('RegionsBulkDialog', () => {
|
||||
expect(wrapper.text()).toContain('7');
|
||||
});
|
||||
|
||||
it('emits apply with computed bitmasks', async () => {
|
||||
it('emits apply with selected subject codes', async () => {
|
||||
const wrapper = mountDialog();
|
||||
// Toggle Центральный (bit 1) in Add
|
||||
await wrapper.find('[data-testid="region-add-1"]').trigger('click');
|
||||
// Toggle Сибирский (bit 64) in Remove
|
||||
await wrapper.find('[data-testid="region-remove-64"]').trigger('click');
|
||||
(wrapper.vm as unknown as DialogVm).addRegions = [82, 83];
|
||||
(wrapper.vm as unknown as DialogVm).removeRegions = [56];
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.find('[data-testid="apply"]').trigger('click');
|
||||
|
||||
expect(wrapper.emitted('apply')?.[0]).toEqual([{ add: 1, remove: 64 }]);
|
||||
expect(wrapper.emitted('apply')?.[0]).toEqual([{ add_regions: [82, 83], remove_regions: [56] }]);
|
||||
});
|
||||
|
||||
it('apply button disabled when both add and remove are 0', () => {
|
||||
it('apply button disabled when nothing selected', () => {
|
||||
const wrapper = mountDialog();
|
||||
const btn = wrapper.find('[data-testid="apply"]');
|
||||
expect(btn.attributes('disabled')).toBeDefined();
|
||||
expect(wrapper.find('[data-testid="apply"]').attributes('disabled')).toBeDefined();
|
||||
});
|
||||
|
||||
it('apply button enabled once a subject is picked', async () => {
|
||||
const wrapper = mountDialog();
|
||||
(wrapper.vm as unknown as DialogVm).addRegions = [82];
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('[data-testid="apply"]').attributes('disabled')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,28 +15,26 @@ describe('SettingsView.vue', () => {
|
||||
expect(wrapper.find('h1').text()).toBe('Настройки');
|
||||
});
|
||||
|
||||
it('содержит ровно 8 nav-tabs', () => {
|
||||
it('содержит ровно 4 nav-tabs (placeholder-вкладки убраны, audit D6/D7)', () => {
|
||||
const wrapper = factory();
|
||||
const items = wrapper.findAll('.tabs-rail .v-list-item');
|
||||
expect(items.length).toBe(8);
|
||||
expect(items.length).toBe(4);
|
||||
});
|
||||
|
||||
it('содержит все 8 названий вкладок', () => {
|
||||
it('содержит все 4 названия рабочих вкладок', () => {
|
||||
const wrapper = factory();
|
||||
const text = wrapper.text();
|
||||
const labels = [
|
||||
'Профиль',
|
||||
'Безопасность',
|
||||
'Проекты',
|
||||
'Команда',
|
||||
'API и Webhook',
|
||||
'Интеграции',
|
||||
'Тихие часы',
|
||||
'Уведомления',
|
||||
];
|
||||
const labels = ['Профиль', 'Безопасность', 'API и Webhook', 'Уведомления'];
|
||||
labels.forEach((l) => expect(text).toContain(l));
|
||||
});
|
||||
|
||||
it('не содержит placeholder-вкладок и текста «В разработке»', () => {
|
||||
const wrapper = factory();
|
||||
const railText = wrapper.find('.tabs-rail').text();
|
||||
['Команда', 'Интеграции', 'Тихие часы'].forEach((l) => expect(railText).not.toContain(l));
|
||||
expect(wrapper.text()).not.toContain('В разработке');
|
||||
});
|
||||
|
||||
it('по умолчанию показывает вкладку «Профиль»', () => {
|
||||
const wrapper = factory();
|
||||
const text = wrapper.text();
|
||||
@@ -46,17 +44,6 @@ describe('SettingsView.vue', () => {
|
||||
expect(text).toContain('Тайм-зона');
|
||||
});
|
||||
|
||||
it('placeholder-вкладки показывают «В разработке»', async () => {
|
||||
const wrapper = factory();
|
||||
// Кликаем по «Проекты» — placeholder-вкладка.
|
||||
const items = wrapper.findAll('.tabs-rail .v-list-item');
|
||||
const projectsItem = items.find((i) => i.text().includes('Проекты'));
|
||||
expect(projectsItem).toBeDefined();
|
||||
await projectsItem!.trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.text()).toContain('В разработке');
|
||||
});
|
||||
|
||||
it('переключение на «Уведомления» показывает матрицу 8×3', async () => {
|
||||
const wrapper = factory();
|
||||
const items = wrapper.findAll('.tabs-rail .v-list-item');
|
||||
|
||||
@@ -66,15 +66,15 @@ describe('UnknownStatusesDialog', () => {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
vm.selection['Архив'] = 'closed';
|
||||
vm.selection['Спам'] = 'closed';
|
||||
vm.selection['Архив'] = 'lost';
|
||||
vm.selection['Спам'] = 'lost';
|
||||
await flushPromises();
|
||||
await vm.save();
|
||||
await flushPromises();
|
||||
|
||||
expect(spy).toHaveBeenCalledWith([
|
||||
{ status_ru: 'Архив', slug: 'closed' },
|
||||
{ status_ru: 'Спам', slug: 'closed' },
|
||||
{ status_ru: 'Архив', slug: 'lost' },
|
||||
{ status_ru: 'Спам', slug: 'lost' },
|
||||
]);
|
||||
expect(wrapper.emitted('resolved')).toBeTruthy();
|
||||
wrapper.unmount();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user