From e426a53f7e78927aaa54a8faa69ceea928346092 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Wed, 1 Apr 2026 00:45:15 +0000 Subject: [PATCH] DTv-1 --- assets/pasted-20260331-220901-00af1a98.jpg | 2 + backend/src/index.js | 2 + backend/src/routes/sales_hub.js | 305 ++++ frontend/public/brand/dakiktabela-logo.svg | 2 + frontend/public/locales/tr/common.json | 52 + frontend/src/components/AsideMenuItem.tsx | 32 +- frontend/src/components/AsideMenuLayer.tsx | 77 +- frontend/src/components/FooterBar.tsx | 25 +- frontend/src/components/LanguageSwitcher.tsx | 170 ++- frontend/src/components/Logo/index.tsx | 6 +- frontend/src/components/NavBarItem.tsx | 5 +- .../src/components/PasswordSetOrReset.tsx | 20 +- frontend/src/components/SearchResults.tsx | 6 +- .../components/SmartWidget/SmartWidget.tsx | 2 +- .../WidgetCreator/WidgetCreator.tsx | 14 +- frontend/src/config.ts | 2 +- frontend/src/css/main.css | 5 + frontend/src/i18n.ts | 2 +- frontend/src/layouts/Authenticated.tsx | 5 +- frontend/src/menuAside.ts | 408 +++-- frontend/src/menuNavBar.ts | 20 +- frontend/src/pages/_app.tsx | 248 +-- frontend/src/pages/dashboard.tsx | 1346 ++++++----------- frontend/src/pages/forgot.tsx | 8 +- frontend/src/pages/index.tsx | 298 ++-- frontend/src/pages/login.tsx | 30 +- frontend/src/pages/profile.tsx | 36 +- frontend/src/pages/register.tsx | 12 +- frontend/src/pages/sales-hub.tsx | 744 +++++++++ frontend/src/pages/search.tsx | 10 +- frontend/src/pages/verify-email.tsx | 4 +- frontend/tailwind.config.js | 3 + 32 files changed, 2300 insertions(+), 1601 deletions(-) create mode 100644 assets/pasted-20260331-220901-00af1a98.jpg create mode 100644 backend/src/routes/sales_hub.js create mode 100644 frontend/public/brand/dakiktabela-logo.svg create mode 100644 frontend/public/locales/tr/common.json create mode 100644 frontend/src/pages/sales-hub.tsx diff --git a/assets/pasted-20260331-220901-00af1a98.jpg b/assets/pasted-20260331-220901-00af1a98.jpg new file mode 100644 index 0000000..f8fa512 --- /dev/null +++ b/assets/pasted-20260331-220901-00af1a98.jpg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/backend/src/index.js b/backend/src/index.js index ae8a2c2..a3e4217 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -20,6 +20,7 @@ const pexelsRoutes = require('./routes/pexels'); const organizationForAuthRoutes = require('./routes/organizationLogin'); const openaiRoutes = require('./routes/openai'); +const salesHubRoutes = require('./routes/sales_hub'); @@ -154,6 +155,7 @@ app.use('/api/leads', passport.authenticate('jwt', {session: false}), leadsRoute app.use('/api/deals', passport.authenticate('jwt', {session: false}), dealsRoutes); app.use('/api/activities', passport.authenticate('jwt', {session: false}), activitiesRoutes); +app.use('/api/sales-hub', passport.authenticate('jwt', {session: false}), salesHubRoutes); app.use('/api/materials', passport.authenticate('jwt', {session: false}), materialsRoutes); diff --git a/backend/src/routes/sales_hub.js b/backend/src/routes/sales_hub.js new file mode 100644 index 0000000..d79c47a --- /dev/null +++ b/backend/src/routes/sales_hub.js @@ -0,0 +1,305 @@ +const express = require('express'); + +const db = require('../db/models'); +const wrapAsync = require('../helpers').wrapAsync; +const ActivitiesDBApi = require('../db/api/activities'); + +const router = express.Router(); +const { QueryTypes } = db.Sequelize; + +const RELATION_MODELS = { + lead: db.leads, + deal: db.deals, + contact: db.contacts, +}; + +const RELATION_LABELS = { + lead: 'Potansiyel müşteri', + deal: 'Fırsat', + contact: 'Kişi', +}; + +const ACTIVITY_TYPES = new Set([ + 'call', + 'email', + 'meeting', + 'task', + 'note', + 'demo', + 'follow_up', +]); + +function getOrganizationId(currentUser) { + return ( + currentUser?.organizations?.id || + currentUser?.organization?.id || + currentUser?.organizationsId || + currentUser?.organizationId || + null + ); +} + +function buildScopedWhere(alias, globalAccess, organizationId, extraClauses = []) { + const clauses = [`${alias}."deletedAt" IS NULL`]; + + if (!globalAccess && organizationId) { + clauses.push(`${alias}."organizationsId" = :organizationId`); + } + + return [...clauses, ...extraClauses].join(' AND '); +} + +async function findScopedEntity(model, id, organizationId, globalAccess) { + const where = { id }; + + if (!globalAccess && organizationId) { + where.organizationsId = organizationId; + } + + return model.findOne({ where }); +} + +router.get( + '/', + wrapAsync(async (req, res) => { + const { currentUser } = req; + const globalAccess = Boolean(currentUser?.app_role?.globalAccess); + const organizationId = getOrganizationId(currentUser); + const replacements = { + userId: currentUser.id, + }; + + if (!globalAccess && organizationId) { + replacements.organizationId = organizationId; + } + + const leadScope = buildScopedWhere('l', globalAccess, organizationId, [ + 'l."ownerId" = :userId', + 'COALESCE(l.status, \'new\') NOT IN (\'disqualified\', \'converted\')', + ]); + const dealScope = buildScopedWhere('d', globalAccess, organizationId, [ + 'd."ownerId" = :userId', + 'COALESCE(d.status, \'open\') = \'open\'', + ]); + const activityOpenScope = buildScopedWhere('a', globalAccess, organizationId, [ + 'a."assigned_toId" = :userId', + 'COALESCE(a.status, \'planned\') NOT IN (\'done\', \'canceled\')', + ]); + const activityDoneScope = buildScopedWhere('a', globalAccess, organizationId, [ + 'a."assigned_toId" = :userId', + 'COALESCE(a.status, \'planned\') = \'done\'', + ]); + + const [statsRows, followUps, recentLeads, stageSummary, quickOptions] = + await Promise.all([ + db.sequelize.query( + `SELECT + (SELECT COUNT(*)::int FROM "leads" l WHERE ${leadScope}) AS "myOpenLeads", + (SELECT COUNT(*)::int FROM "deals" d WHERE ${dealScope}) AS "myActiveDeals", + (SELECT COUNT(*)::int FROM "activities" a WHERE ${activityOpenScope} AND a."due_at" IS NOT NULL AND DATE(a."due_at") = CURRENT_DATE) AS "dueToday", + (SELECT COUNT(*)::int FROM "activities" a WHERE ${activityOpenScope} AND a."due_at" IS NOT NULL AND a."due_at" < NOW()) AS "overdue", + (SELECT COUNT(*)::int FROM "activities" a WHERE ${activityDoneScope} AND a."completed_at" >= NOW() - INTERVAL '7 days') AS "completedThisWeek"`, + { + replacements, + type: QueryTypes.SELECT, + }, + ), + db.sequelize.query( + `SELECT + a.id, + a.subject, + a.status, + a."activity_type" AS "activityType", + a."due_at" AS "dueAt", + COALESCE(d.name, l.title, NULLIF(TRIM(CONCAT(c.first_name, ' ', c.last_name)), ''), c.email, 'Unlinked activity') AS "relatedName", + CASE + WHEN a."dealId" IS NOT NULL THEN 'deal' + WHEN a."leadId" IS NOT NULL THEN 'lead' + WHEN a."contactId" IS NOT NULL THEN 'contact' + ELSE 'activity' + END AS "relatedType", + COALESCE(a."dealId", a."leadId", a."contactId") AS "relatedId" + FROM "activities" a + LEFT JOIN "deals" d ON d.id = a."dealId" AND d."deletedAt" IS NULL + LEFT JOIN "leads" l ON l.id = a."leadId" AND l."deletedAt" IS NULL + LEFT JOIN "contacts" c ON c.id = a."contactId" AND c."deletedAt" IS NULL + WHERE ${activityOpenScope} + ORDER BY + CASE WHEN a."due_at" IS NULL THEN 1 ELSE 0 END, + a."due_at" ASC, + a."createdAt" DESC + LIMIT 8`, + { + replacements, + type: QueryTypes.SELECT, + }, + ), + db.sequelize.query( + `SELECT + l.id, + l.title, + l.status, + l."estimated_value" AS "estimatedValue", + l."next_follow_up_at" AS "nextFollowUpAt", + c.name AS "companyName", + ps.name AS "stageName" + FROM "leads" l + LEFT JOIN "companies" c ON c.id = l."companyId" AND c."deletedAt" IS NULL + LEFT JOIN "pipeline_stages" ps ON ps.id = l."stageId" AND ps."deletedAt" IS NULL + WHERE ${leadScope} + ORDER BY l."createdAt" DESC + LIMIT 6`, + { + replacements, + type: QueryTypes.SELECT, + }, + ), + db.sequelize.query( + `SELECT + COALESCE(ps.id::text, 'unassigned') AS id, + COALESCE(ps.name, 'Unassigned') AS name, + COUNT(d.id)::int AS "dealCount", + COALESCE(SUM(d.amount), 0)::float AS amount + FROM "deals" d + LEFT JOIN "pipeline_stages" ps ON ps.id = d."stageId" AND ps."deletedAt" IS NULL + WHERE ${dealScope} + GROUP BY COALESCE(ps.id::text, 'unassigned'), COALESCE(ps.name, 'Unassigned'), COALESCE(ps."sort_order", 999) + ORDER BY COALESCE(ps."sort_order", 999) ASC, COALESCE(ps.name, 'Unassigned') ASC`, + { + replacements, + type: QueryTypes.SELECT, + }, + ), + Promise.all([ + db.sequelize.query( + `SELECT l.id, l.title AS label + FROM "leads" l + WHERE ${leadScope} + ORDER BY l."updatedAt" DESC + LIMIT 8`, + { + replacements, + type: QueryTypes.SELECT, + }, + ), + db.sequelize.query( + `SELECT d.id, d.name AS label + FROM "deals" d + WHERE ${dealScope} + ORDER BY d."updatedAt" DESC + LIMIT 8`, + { + replacements, + type: QueryTypes.SELECT, + }, + ), + db.sequelize.query( + `SELECT c.id, COALESCE(NULLIF(TRIM(CONCAT(c.first_name, ' ', c.last_name)), ''), c.email, 'Untitled contact') AS label + FROM "contacts" c + WHERE ${buildScopedWhere('c', globalAccess, organizationId, ['c."ownerId" = :userId'])} + ORDER BY c."updatedAt" DESC + LIMIT 8`, + { + replacements, + type: QueryTypes.SELECT, + }, + ), + ]), + ]); + + return res.status(200).json({ + stats: statsRows[0] || { + myOpenLeads: 0, + myActiveDeals: 0, + dueToday: 0, + overdue: 0, + completedThisWeek: 0, + }, + followUps, + recentLeads, + stageSummary, + quickOptions: { + leads: quickOptions[0], + deals: quickOptions[1], + contacts: quickOptions[2], + }, + }); + }), +); + +router.post( + '/follow-ups', + wrapAsync(async (req, res) => { + const { currentUser } = req; + const globalAccess = Boolean(currentUser?.app_role?.globalAccess); + const organizationId = getOrganizationId(currentUser); + const data = req.body?.data || {}; + const subject = typeof data.subject === 'string' ? data.subject.trim() : ''; + const details = typeof data.details === 'string' ? data.details.trim() : ''; + const relationType = data.relationType; + const relationId = data.relationId; + const activityType = ACTIVITY_TYPES.has(data.activity_type) + ? data.activity_type + : 'follow_up'; + + if (!subject) { + return res.status(400).json({ message: 'Konu alanı zorunludur.' }); + } + + if (!data.due_at) { + return res.status(400).json({ message: 'Termin tarihi zorunludur.' }); + } + + if (!RELATION_MODELS[relationType] || !relationId) { + return res.status(400).json({ + message: 'Takibin bağlanacağı bir potansiyel müşteri, fırsat veya kişi seçin.', + }); + } + + const relationEntity = await findScopedEntity( + RELATION_MODELS[relationType], + relationId, + organizationId, + globalAccess, + ); + + if (!relationEntity) { + return res.status(400).json({ + message: `${RELATION_LABELS[relationType]} bulunamadı veya erişilemez durumda.`, + }); + } + + const transaction = await db.sequelize.transaction(); + + try { + const activity = await ActivitiesDBApi.create( + { + activity_type: activityType, + status: 'planned', + subject, + details: details || null, + due_at: data.due_at, + assigned_to: currentUser.id, + organizations: organizationId, + [relationType]: relationId, + }, + { + currentUser, + transaction, + }, + ); + + await transaction.commit(); + + return res.status(200).json({ + id: activity.id, + subject: activity.subject, + }); + } catch (error) { + await transaction.rollback(); + throw error; + } + }), +); + +module.exports = router; diff --git a/frontend/public/brand/dakiktabela-logo.svg b/frontend/public/brand/dakiktabela-logo.svg new file mode 100644 index 0000000..f8fa512 --- /dev/null +++ b/frontend/public/brand/dakiktabela-logo.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/frontend/public/locales/tr/common.json b/frontend/public/locales/tr/common.json new file mode 100644 index 0000000..8d45685 --- /dev/null +++ b/frontend/public/locales/tr/common.json @@ -0,0 +1,52 @@ +{ + "pages": { + "dashboard": { + "pageTitle": "Dashboard", + "overview": "Overview", + "loadingWidgets": "Loading widgets...", + "loading": "Loading..." + }, + "login": { + "pageTitle": "Login", + + "form": { + "loginLabel": "Login", + "loginHelp": "Please enter your login", + "passwordLabel": "Password", + "passwordHelp": "Please enter your password", + "remember": "Remember", + "forgotPassword": "Forgot password?", + "loginButton": "Login", + "loading": "Loading...", + "noAccountYet": "Don’t have an account yet?", + "newAccount": "New Account" + }, + + "pexels": { + "photoCredit": "Photo by {{photographer}} on Pexels", + "videoCredit": "Video by {{name}} on Pexels", + "videoUnsupported": "Your browser does not support the video tag." + }, + + "footer": { + "copyright": "© {{year}} {{title}}. All rights reserved", + "privacy": "Privacy Policy" + } + } + }, + "components": { + "widgetCreator": { + "title": "Create Chart or Widget", + "helpText": "Describe your new widget or chart in natural language. For example: \"Number of admin users\" OR \"red chart with number of closed contracts grouped by month\"", + "settingsTitle": "Widget Creator Settings", + "settingsDescription": "What role are we showing and creating widgets for?", + "doneButton": "Done", + "loading": "Loading..." + }, + "search": { + "placeholder": "Search", + "required": "Required", + "minLength": "Minimum length: {{count}} characters" + } + } +} diff --git a/frontend/src/components/AsideMenuItem.tsx b/frontend/src/components/AsideMenuItem.tsx index dbb09b2..1c7ade5 100644 --- a/frontend/src/components/AsideMenuItem.tsx +++ b/frontend/src/components/AsideMenuItem.tsx @@ -29,16 +29,30 @@ const AsideMenuItem = ({ item, isDropdownList = false }: Props) => { const { asPath, isReady } = useRouter() useEffect(() => { - if (item.href && isReady) { - const linkPathName = new URL(item.href, location.href).pathname + '/'; - const activePathname = new URL(asPath, location.href).pathname - - const activeView = activePathname.split('/')[1]; - const linkPathNameView = linkPathName.split('/')[1]; - - setIsLinkActive(linkPathNameView === activeView); + if (!isReady) { + return } - }, [item.href, isReady, asPath]) + + const activePathname = new URL(asPath, location.href).pathname + + if (item.href) { + const linkPathName = new URL(item.href, location.href).pathname + '/' + const activeView = activePathname.split('/')[1] + const linkPathNameView = linkPathName.split('/')[1] + setIsLinkActive(linkPathNameView === activeView) + } + + if (item.menu?.length) { + const hasActiveChild = item.menu.some((child) => { + if (!child.href) return false + const childPath = new URL(child.href, location.href).pathname + return childPath.split('/')[1] === activePathname.split('/')[1] + }) + + setIsDropdownActive(hasActiveChild) + setIsLinkActive(hasActiveChild) + } + }, [item.href, item.menu, isReady, asPath]) const asideMenuItemInnerContents = ( <> diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index d09f7e1..ace1334 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -1,15 +1,12 @@ import React from 'react' -import { mdiLogout, mdiClose } from '@mdi/js' +import { mdiClose } from '@mdi/js' import BaseIcon from './BaseIcon' import AsideMenuList from './AsideMenuList' import { MenuAsideItem } from '../interfaces' -import { useAppSelector } from '../stores/hooks' -import Link from 'next/link'; - -import { useAppDispatch } from '../stores/hooks'; -import { createAsyncThunk } from '@reduxjs/toolkit'; -import axios from 'axios'; - +import { useAppDispatch, useAppSelector } from '../stores/hooks' +import { createAsyncThunk } from '@reduxjs/toolkit' +import axios from 'axios' +import Logo from './Logo' type Props = { menu: MenuAsideItem[] @@ -18,7 +15,7 @@ type Props = { } export default function AsideMenuLayer({ menu, className = '', ...props }: Props) { - const corners = useAppSelector((state) => state.style.corners); + const corners = useAppSelector((state) => state.style.corners) const asideStyle = useAppSelector((state) => state.style.asideStyle) const asideBrandStyle = useAppSelector((state) => state.style.asideBrandStyle) const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle) @@ -29,55 +26,49 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props props.onAsideLgCloseClick() } - const dispatch = useAppDispatch(); - const { currentUser } = useAppSelector((state) => state.auth); - const organizationsId = currentUser?.organizations?.id; - const [organizations, setOrganizations] = React.useState(null); + const dispatch = useAppDispatch() + const { currentUser } = useAppSelector((state) => state.auth) + const organizationsId = currentUser?.organizations?.id + const [organizations, setOrganizations] = React.useState([]) const fetchOrganizations = createAsyncThunk('/org-for-auth', async () => { try { - const response = await axios.get('/org-for-auth'); - setOrganizations(response.data); - return response.data; - } catch (error) { - console.error(error.response); - throw error; + const response = await axios.get('/org-for-auth') + setOrganizations(response.data) + return response.data + } catch (error: any) { + console.error(error?.response || error) + throw error } - }); + }) React.useEffect(() => { - dispatch(fetchOrganizations()); - }, [dispatch]); + dispatch(fetchOrganizations()) + }, [dispatch]) - let organizationName = organizations?.find(item => item.id === organizationsId)?.name; - if(organizationName?.length > 25){ - organizationName = organizationName?.substring(0, 25) + '...'; + let organizationName = organizations?.find((item) => item.id === organizationsId)?.name + if (organizationName?.length > 28) { + organizationName = organizationName.substring(0, 28) + '...' } - return (