AnalyticsSentimentDashboardModel.php 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636
  1. <?php
  2. namespace Models;
  3. use Libs\Database;
  4. class AnalyticsSentimentDashboardModel
  5. {
  6. private \PDO $pdo;
  7. public function __construct()
  8. {
  9. $this->pdo = Database::pdo();
  10. }
  11. public function getDashboardData(int $companyId, array $queryParams): array
  12. {
  13. $filters = $this->normalizeFilters($queryParams);
  14. $summaryRange = $this->resolveRange($filters['timeframe']);
  15. return [
  16. 'summaryCards' => $this->getSummaryCards($companyId, $filters, $summaryRange),
  17. 'alerts' => $this->getAlerts($companyId, $filters, $summaryRange),
  18. 'timelineViews' => [
  19. 'day' => $this->getDayTimeline($companyId, $filters),
  20. 'week' => $this->getWeekTimeline($companyId, $filters),
  21. 'month' => $this->getMonthTimeline($companyId, $filters),
  22. ],
  23. 'aspects' => $this->getAspects($companyId, $filters, $summaryRange),
  24. ];
  25. }
  26. private function normalizeFilters(array $queryParams): array
  27. {
  28. $timeframe = strtolower(trim((string) ($queryParams['timeframe'] ?? 'week')));
  29. if (!in_array($timeframe, ['day', 'week', 'month'], true)) {
  30. $timeframe = 'week';
  31. }
  32. $sentiment = strtolower(trim((string) ($queryParams['sentiment'] ?? 'all')));
  33. if (!in_array($sentiment, ['all', 'positive', 'neutral', 'negative'], true)) {
  34. $sentiment = 'all';
  35. }
  36. $aspect = trim((string) ($queryParams['aspect'] ?? ''));
  37. if ($aspect === '' || strtolower($aspect) === 'all') {
  38. $aspect = null;
  39. }
  40. return [
  41. 'timeframe' => $timeframe,
  42. 'sentiment' => $sentiment,
  43. 'aspect' => $aspect !== null ? mb_strtolower($aspect) : null,
  44. ];
  45. }
  46. private function resolveRange(string $timeframe): array
  47. {
  48. $today = new \DateTimeImmutable('today');
  49. if ($timeframe === 'day') {
  50. $start = $today;
  51. } elseif ($timeframe === 'month') {
  52. $start = $today->modify('-29 days');
  53. } else {
  54. $start = $today->modify('-6 days');
  55. }
  56. return [
  57. 'start_datetime' => $start->format('Y-m-d 00:00:00'),
  58. 'end_exclusive_datetime' => $today->modify('+1 day')->format('Y-m-d 00:00:00'),
  59. ];
  60. }
  61. private function getSummaryCards(int $companyId, array $filters, array $range): array
  62. {
  63. $atRiskClients = $this->getAlertClientCountByTypes(
  64. $companyId,
  65. ['churn_risk', 'frustration', 'risk', 'at_risk'],
  66. $range
  67. );
  68. $opportunities = $this->getAlertClientCountByTypes(
  69. $companyId,
  70. ['buying_intent', 'opportunity', 'upsell', 'cross_sell'],
  71. $range
  72. );
  73. $recentInteractions = $this->getRecentInteractionsCount($companyId, $filters, $range);
  74. $netTrend = $this->getNetTrend($companyId, $filters, $range);
  75. return [
  76. [
  77. 'id' => 'atRiskClients',
  78. 'label' => 'Clientes em risco',
  79. 'value' => $atRiskClients,
  80. 'image' => '/images/sentiment/risk.svg',
  81. ],
  82. [
  83. 'id' => 'opportunities',
  84. 'label' => 'Oportunidades',
  85. 'value' => $opportunities,
  86. 'image' => '/images/sentiment/opportunity.svg',
  87. ],
  88. [
  89. 'id' => 'recentInteractions',
  90. 'label' => 'Interacoes recentes',
  91. 'value' => $recentInteractions,
  92. 'image' => '/images/sentiment/interactions.svg',
  93. ],
  94. [
  95. 'id' => 'netTrend',
  96. 'label' => 'Tendencia liquida',
  97. 'value' => $netTrend,
  98. 'image' => '/images/sentiment/trend.svg',
  99. ],
  100. ];
  101. }
  102. private function getAlertClientCountByTypes(int $companyId, array $types, array $range): int
  103. {
  104. $placeholders = [];
  105. $params = [
  106. 'company_id' => $companyId,
  107. 'start_datetime' => $range['start_datetime'],
  108. 'end_exclusive_datetime' => $range['end_exclusive_datetime'],
  109. ];
  110. foreach ($types as $index => $type) {
  111. $key = 'type_' . $index;
  112. $placeholders[] = ':' . $key;
  113. $params[$key] = $type;
  114. }
  115. $stmt = $this->pdo->prepare(
  116. "SELECT COUNT(DISTINCT client_id)
  117. FROM alert
  118. WHERE company_id = :company_id
  119. AND alert_deleted_at = 'infinity'
  120. AND alert_is_resolved = FALSE
  121. AND alert_created_at >= :start_datetime
  122. AND alert_created_at < :end_exclusive_datetime
  123. AND lower(alert_type) IN (" . implode(', ', $placeholders) . ')'
  124. );
  125. $stmt->execute($params);
  126. return (int) $stmt->fetchColumn();
  127. }
  128. private function getRecentInteractionsCount(int $companyId, array $filters, array $range): int
  129. {
  130. $sql = "SELECT COUNT(DISTINCT c.conversation_id)
  131. FROM conversation c
  132. LEFT JOIN conversation_analysis ca
  133. ON ca.conversation_id = c.conversation_id
  134. AND ca.conversation_analysis_deleted_at = 'infinity'
  135. WHERE c.company_id = :company_id
  136. AND c.conversation_deleted_at = 'infinity'
  137. AND c.conversation_last_message_at >= :start_datetime
  138. AND c.conversation_last_message_at < :end_exclusive_datetime";
  139. $params = [
  140. 'company_id' => $companyId,
  141. 'start_datetime' => $range['start_datetime'],
  142. 'end_exclusive_datetime' => $range['end_exclusive_datetime'],
  143. ];
  144. $this->appendConversationAnalysisFilters($sql, $params, 'ca', $filters);
  145. $stmt = $this->pdo->prepare($sql);
  146. $stmt->execute($params);
  147. return (int) $stmt->fetchColumn();
  148. }
  149. private function getNetTrend(int $companyId, array $filters, array $range): string
  150. {
  151. $sql = "SELECT
  152. COALESCE(SUM(CASE WHEN po.opinion_is_positive THEN 1 ELSE 0 END), 0) AS gains,
  153. COALESCE(SUM(CASE WHEN po.opinion_is_positive THEN 0 ELSE 1 END), 0) AS losses
  154. FROM public_opinion po
  155. INNER JOIN conversation c
  156. ON c.conversation_id = po.conversation_id
  157. AND c.conversation_deleted_at = 'infinity'
  158. LEFT JOIN conversation_analysis ca
  159. ON ca.conversation_id = po.conversation_id
  160. AND ca.conversation_analysis_deleted_at = 'infinity'
  161. WHERE po.company_id = :company_id
  162. AND po.opinion_deleted_at = 'infinity'
  163. AND po.opinion_classified_at >= :start_datetime
  164. AND po.opinion_classified_at < :end_exclusive_datetime";
  165. $params = [
  166. 'company_id' => $companyId,
  167. 'start_datetime' => $range['start_datetime'],
  168. 'end_exclusive_datetime' => $range['end_exclusive_datetime'],
  169. ];
  170. $this->appendConversationAnalysisFilters($sql, $params, 'ca', $filters);
  171. $stmt = $this->pdo->prepare($sql);
  172. $stmt->execute($params);
  173. $row = $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
  174. $gains = (int) ($row['gains'] ?? 0);
  175. $losses = (int) ($row['losses'] ?? 0);
  176. $total = $gains + $losses;
  177. if ($total === 0) {
  178. return '0%';
  179. }
  180. $percentage = (int) round((($gains - $losses) / $total) * 100);
  181. if ($percentage > 0) {
  182. return '+' . $percentage . '%';
  183. }
  184. return $percentage . '%';
  185. }
  186. private function getAlerts(int $companyId, array $filters, array $range): array
  187. {
  188. $sql = "SELECT
  189. a.alert_id,
  190. a.client_id,
  191. cl.client_name,
  192. a.alert_title,
  193. a.alert_description,
  194. a.alert_priority,
  195. a.alert_type,
  196. a.alert_created_at
  197. FROM alert a
  198. INNER JOIN client cl
  199. ON cl.client_id = a.client_id
  200. AND cl.client_deleted_at = 'infinity'
  201. WHERE a.company_id = :company_id
  202. AND a.alert_deleted_at = 'infinity'
  203. AND a.alert_is_resolved = FALSE
  204. AND a.alert_created_at >= :start_datetime
  205. AND a.alert_created_at < :end_exclusive_datetime";
  206. $params = [
  207. 'company_id' => $companyId,
  208. 'start_datetime' => $range['start_datetime'],
  209. 'end_exclusive_datetime' => $range['end_exclusive_datetime'],
  210. ];
  211. if ($filters['aspect'] !== null || $filters['sentiment'] !== 'all') {
  212. $sql .= "
  213. AND EXISTS (
  214. SELECT 1
  215. FROM conversation c
  216. INNER JOIN conversation_analysis ca
  217. ON ca.conversation_id = c.conversation_id
  218. AND ca.conversation_analysis_deleted_at = 'infinity'
  219. WHERE c.company_id = a.company_id
  220. AND c.client_id = a.client_id
  221. AND c.conversation_deleted_at = 'infinity'
  222. AND c.conversation_last_message_at >= :filter_start_datetime
  223. AND c.conversation_last_message_at < :filter_end_exclusive_datetime";
  224. $params['filter_start_datetime'] = $range['start_datetime'];
  225. $params['filter_end_exclusive_datetime'] = $range['end_exclusive_datetime'];
  226. $this->appendConversationAnalysisFilters($sql, $params, 'ca', $filters);
  227. $sql .= "
  228. )";
  229. }
  230. $sql .= "
  231. ORDER BY
  232. CASE lower(a.alert_priority)
  233. WHEN 'high' THEN 1
  234. WHEN 'medium' THEN 2
  235. ELSE 3
  236. END,
  237. a.alert_created_at DESC
  238. LIMIT 10";
  239. $stmt = $this->pdo->prepare($sql);
  240. $stmt->execute($params);
  241. $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
  242. return array_map(function (array $row): array {
  243. $priority = $this->normalizeAlertPriority((string) ($row['alert_priority'] ?? 'low'));
  244. return [
  245. 'id' => 'alert-' . (int) $row['alert_id'],
  246. 'clientId' => (int) ($row['client_id'] ?? 0),
  247. 'clientName' => $row['client_name'] ?? '',
  248. 'title' => $row['alert_title'] ?? '',
  249. 'description' => $row['alert_description'] ?? '',
  250. 'priority' => $priority,
  251. 'priorityLabel' => $this->getPriorityLabel($priority),
  252. 'category' => $this->normalizeAlertCategory((string) ($row['alert_type'] ?? '')),
  253. ];
  254. }, $rows);
  255. }
  256. private function getDayTimeline(int $companyId, array $filters): array
  257. {
  258. $today = new \DateTimeImmutable('today');
  259. $start = $today->modify('-6 days');
  260. $range = [
  261. 'start_datetime' => $start->format('Y-m-d 00:00:00'),
  262. 'end_exclusive_datetime' => $today->modify('+1 day')->format('Y-m-d 00:00:00'),
  263. ];
  264. $stats = $this->getOpinionBuckets(
  265. $companyId,
  266. $filters,
  267. $range,
  268. "TO_CHAR(DATE(po.opinion_classified_at), 'YYYY-MM-DD')"
  269. );
  270. $items = [];
  271. for ($i = 0; $i < 7; $i++) {
  272. $bucketDate = $start->modify('+' . $i . ' days')->format('Y-m-d');
  273. $items[] = [
  274. 'period' => 'Dia ' . ($i + 1),
  275. 'gains' => (int) ($stats[$bucketDate]['gains'] ?? 0),
  276. 'losses' => (int) ($stats[$bucketDate]['losses'] ?? 0),
  277. ];
  278. }
  279. return $items;
  280. }
  281. private function getWeekTimeline(int $companyId, array $filters): array
  282. {
  283. $currentWeek = new \DateTimeImmutable('monday this week');
  284. $start = $currentWeek->modify('-5 weeks');
  285. $range = [
  286. 'start_datetime' => $start->format('Y-m-d 00:00:00'),
  287. 'end_exclusive_datetime' => $currentWeek->modify('+1 week')->format('Y-m-d 00:00:00'),
  288. ];
  289. $stats = $this->getOpinionBuckets(
  290. $companyId,
  291. $filters,
  292. $range,
  293. "TO_CHAR(DATE_TRUNC('week', po.opinion_classified_at), 'YYYY-MM-DD')"
  294. );
  295. $items = [];
  296. for ($i = 0; $i < 6; $i++) {
  297. $bucketDate = $start->modify('+' . $i . ' weeks')->format('Y-m-d');
  298. $items[] = [
  299. 'period' => 'Sem ' . ($i + 1),
  300. 'gains' => (int) ($stats[$bucketDate]['gains'] ?? 0),
  301. 'losses' => (int) ($stats[$bucketDate]['losses'] ?? 0),
  302. ];
  303. }
  304. return $items;
  305. }
  306. private function getMonthTimeline(int $companyId, array $filters): array
  307. {
  308. $currentMonth = new \DateTimeImmutable('first day of this month');
  309. $start = $currentMonth->modify('-5 months');
  310. $range = [
  311. 'start_datetime' => $start->format('Y-m-d 00:00:00'),
  312. 'end_exclusive_datetime' => $currentMonth->modify('+1 month')->format('Y-m-d 00:00:00'),
  313. ];
  314. $stats = $this->getOpinionBuckets(
  315. $companyId,
  316. $filters,
  317. $range,
  318. "TO_CHAR(DATE_TRUNC('month', po.opinion_classified_at), 'YYYY-MM-DD')"
  319. );
  320. $items = [];
  321. for ($i = 0; $i < 6; $i++) {
  322. $bucketDate = $start->modify('+' . $i . ' months')->format('Y-m-d');
  323. $items[] = [
  324. 'period' => $this->formatMonthLabel($start->modify('+' . $i . ' months')),
  325. 'gains' => (int) ($stats[$bucketDate]['gains'] ?? 0),
  326. 'losses' => (int) ($stats[$bucketDate]['losses'] ?? 0),
  327. ];
  328. }
  329. return $items;
  330. }
  331. private function getOpinionBuckets(int $companyId, array $filters, array $range, string $bucketSql): array
  332. {
  333. $sql = "SELECT
  334. {$bucketSql} AS bucket_key,
  335. COALESCE(SUM(CASE WHEN po.opinion_is_positive THEN 1 ELSE 0 END), 0) AS gains,
  336. COALESCE(SUM(CASE WHEN po.opinion_is_positive THEN 0 ELSE 1 END), 0) AS losses
  337. FROM public_opinion po
  338. INNER JOIN conversation c
  339. ON c.conversation_id = po.conversation_id
  340. AND c.conversation_deleted_at = 'infinity'
  341. LEFT JOIN conversation_analysis ca
  342. ON ca.conversation_id = po.conversation_id
  343. AND ca.conversation_analysis_deleted_at = 'infinity'
  344. WHERE po.company_id = :company_id
  345. AND po.opinion_deleted_at = 'infinity'
  346. AND po.opinion_classified_at >= :start_datetime
  347. AND po.opinion_classified_at < :end_exclusive_datetime";
  348. $params = [
  349. 'company_id' => $companyId,
  350. 'start_datetime' => $range['start_datetime'],
  351. 'end_exclusive_datetime' => $range['end_exclusive_datetime'],
  352. ];
  353. $this->appendConversationAnalysisFilters($sql, $params, 'ca', $filters);
  354. $sql .= "
  355. GROUP BY bucket_key
  356. ORDER BY bucket_key ASC";
  357. $stmt = $this->pdo->prepare($sql);
  358. $stmt->execute($params);
  359. $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
  360. $mapped = [];
  361. foreach ($rows as $row) {
  362. $mapped[$row['bucket_key']] = [
  363. 'gains' => (int) ($row['gains'] ?? 0),
  364. 'losses' => (int) ($row['losses'] ?? 0),
  365. ];
  366. }
  367. return $mapped;
  368. }
  369. private function getAspects(int $companyId, array $filters, array $range): array
  370. {
  371. $sql = "SELECT
  372. af.aspect_feedback_aspect,
  373. af.aspect_feedback_sentiment,
  374. af.aspect_feedback_text,
  375. cl.client_name,
  376. cl.client_phone,
  377. af.aspect_feedback_created_at
  378. FROM aspect_feedback af
  379. INNER JOIN conversation c
  380. ON c.conversation_id = af.conversation_id
  381. AND c.conversation_deleted_at = 'infinity'
  382. INNER JOIN client cl
  383. ON cl.client_id = c.client_id
  384. AND cl.client_deleted_at = 'infinity'
  385. WHERE af.company_id = :company_id
  386. AND af.aspect_feedback_deleted_at = 'infinity'
  387. AND af.aspect_feedback_created_at >= :start_datetime
  388. AND af.aspect_feedback_created_at < :end_exclusive_datetime";
  389. $params = [
  390. 'company_id' => $companyId,
  391. 'start_datetime' => $range['start_datetime'],
  392. 'end_exclusive_datetime' => $range['end_exclusive_datetime'],
  393. ];
  394. if ($filters['aspect'] !== null) {
  395. $sql .= " AND lower(af.aspect_feedback_aspect) = :aspect";
  396. $params['aspect'] = $filters['aspect'];
  397. }
  398. if ($filters['sentiment'] !== 'all') {
  399. $sql .= ' AND ' . $this->getAspectSentimentWhereClause('af', $filters['sentiment']);
  400. }
  401. $sql .= "
  402. ORDER BY af.aspect_feedback_aspect ASC, af.aspect_feedback_created_at DESC";
  403. $stmt = $this->pdo->prepare($sql);
  404. $stmt->execute($params);
  405. $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
  406. $grouped = [];
  407. foreach ($rows as $row) {
  408. $name = (string) ($row['aspect_feedback_aspect'] ?? '');
  409. $id = $this->slugify($name);
  410. if (!isset($grouped[$id])) {
  411. $grouped[$id] = [
  412. 'id' => $id,
  413. 'name' => $name,
  414. 'volume' => 0,
  415. 'positive' => [],
  416. 'neutral' => [],
  417. 'negative' => [],
  418. ];
  419. }
  420. $sentiment = $this->normalizeAspectSentiment((string) ($row['aspect_feedback_sentiment'] ?? 'neutral'));
  421. $grouped[$id]['volume']++;
  422. $grouped[$id][$sentiment][] = [
  423. 'text' => $row['aspect_feedback_text'] ?? '',
  424. 'client' => $row['client_name'] !== '' ? $row['client_name'] : ($row['client_phone'] ?? ''),
  425. ];
  426. }
  427. usort($grouped, static function (array $left, array $right): int {
  428. return $right['volume'] <=> $left['volume'];
  429. });
  430. return array_values($grouped);
  431. }
  432. private function appendConversationAnalysisFilters(string &$sql, array &$params, string $alias, array $filters): void
  433. {
  434. if ($filters['aspect'] !== null) {
  435. $sql .= " AND lower(COALESCE({$alias}.conversation_analysis_aspect, '')) = :aspect";
  436. $params['aspect'] = $filters['aspect'];
  437. }
  438. if ($filters['sentiment'] !== 'all') {
  439. $sql .= ' AND ' . $this->getConversationSentimentWhereClause($alias, $filters['sentiment']);
  440. }
  441. }
  442. private function getConversationSentimentWhereClause(string $alias, string $sentiment): string
  443. {
  444. if ($sentiment === 'positive') {
  445. return "(
  446. lower(COALESCE({$alias}.conversation_analysis_sentiment, '')) IN ('positive', 'positivo')
  447. OR {$alias}.conversation_analysis_sentiment_score >= 0.15
  448. )";
  449. }
  450. if ($sentiment === 'negative') {
  451. return "(
  452. lower(COALESCE({$alias}.conversation_analysis_sentiment, '')) IN ('negative', 'negativo')
  453. OR {$alias}.conversation_analysis_sentiment_score <= -0.15
  454. )";
  455. }
  456. return "(
  457. {$alias}.conversation_id IS NOT NULL
  458. AND lower(COALESCE({$alias}.conversation_analysis_sentiment, '')) NOT IN ('positive', 'positivo', 'negative', 'negativo')
  459. AND {$alias}.conversation_analysis_sentiment_score > -0.15
  460. AND {$alias}.conversation_analysis_sentiment_score < 0.15
  461. )";
  462. }
  463. private function getAspectSentimentWhereClause(string $alias, string $sentiment): string
  464. {
  465. if ($sentiment === 'positive') {
  466. return "lower({$alias}.aspect_feedback_sentiment) IN ('positive', 'positivo')";
  467. }
  468. if ($sentiment === 'negative') {
  469. return "lower({$alias}.aspect_feedback_sentiment) IN ('negative', 'negativo')";
  470. }
  471. return "lower({$alias}.aspect_feedback_sentiment) NOT IN ('positive', 'positivo', 'negative', 'negativo')";
  472. }
  473. private function normalizeAspectSentiment(string $sentiment): string
  474. {
  475. $normalized = mb_strtolower(trim($sentiment));
  476. if (in_array($normalized, ['positive', 'positivo'], true)) {
  477. return 'positive';
  478. }
  479. if (in_array($normalized, ['negative', 'negativo'], true)) {
  480. return 'negative';
  481. }
  482. return 'neutral';
  483. }
  484. private function normalizeAlertPriority(string $priority): string
  485. {
  486. $normalized = mb_strtolower(trim($priority));
  487. if (in_array($normalized, ['high', 'alta'], true)) {
  488. return 'high';
  489. }
  490. if (in_array($normalized, ['medium', 'media', 'média'], true)) {
  491. return 'medium';
  492. }
  493. return 'low';
  494. }
  495. private function getPriorityLabel(string $priority): string
  496. {
  497. if ($priority === 'high') {
  498. return 'Alta prioridade';
  499. }
  500. if ($priority === 'medium') {
  501. return 'Media prioridade';
  502. }
  503. return 'Baixa prioridade';
  504. }
  505. private function normalizeAlertCategory(string $type): string
  506. {
  507. $normalized = mb_strtolower(trim($type));
  508. if (in_array($normalized, ['buying_intent', 'opportunity', 'upsell', 'cross_sell'], true)) {
  509. return 'buying_intent';
  510. }
  511. if (in_array($normalized, ['frustration', 'frustracao', 'frustração'], true)) {
  512. return 'frustration';
  513. }
  514. return 'churn_risk';
  515. }
  516. private function formatMonthLabel(\DateTimeImmutable $date): string
  517. {
  518. $labels = [
  519. '01' => 'Jan',
  520. '02' => 'Fev',
  521. '03' => 'Mar',
  522. '04' => 'Abr',
  523. '05' => 'Mai',
  524. '06' => 'Jun',
  525. '07' => 'Jul',
  526. '08' => 'Ago',
  527. '09' => 'Set',
  528. '10' => 'Out',
  529. '11' => 'Nov',
  530. '12' => 'Dez',
  531. ];
  532. return $labels[$date->format('m')] ?? $date->format('m');
  533. }
  534. private function slugify(string $value): string
  535. {
  536. $normalized = trim($value);
  537. if ($normalized === '') {
  538. return 'aspect';
  539. }
  540. if (function_exists('iconv')) {
  541. $converted = iconv('UTF-8', 'ASCII//TRANSLIT', $normalized);
  542. if (is_string($converted) && $converted !== '') {
  543. $normalized = $converted;
  544. }
  545. }
  546. $normalized = mb_strtolower($normalized);
  547. $normalized = preg_replace('/[^a-z0-9]+/', '-', $normalized) ?? 'aspect';
  548. $normalized = trim($normalized, '-');
  549. return $normalized !== '' ? $normalized : 'aspect';
  550. }
  551. }