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 (