DTv-1
This commit is contained in:
parent
d7b7b23e2a
commit
e426a53f7e
2
assets/pasted-20260331-220901-00af1a98.jpg
Normal file
2
assets/pasted-20260331-220901-00af1a98.jpg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 499 KiB |
@ -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);
|
||||
|
||||
|
||||
305
backend/src/routes/sales_hub.js
Normal file
305
backend/src/routes/sales_hub.js
Normal file
@ -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;
|
||||
2
frontend/public/brand/dakiktabela-logo.svg
Normal file
2
frontend/public/brand/dakiktabela-logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 499 KiB |
52
frontend/public/locales/tr/common.json
Normal file
52
frontend/public/locales/tr/common.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 = (
|
||||
<>
|
||||
|
||||
@ -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<any[]>([])
|
||||
|
||||
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 (
|
||||
<aside
|
||||
id='asideMenu'
|
||||
className={`${className} zzz lg:py-2 lg:pl-2 w-60 fixed flex z-40 top-0 h-screen transition-position overflow-hidden`}
|
||||
>
|
||||
<div
|
||||
className={`flex-1 flex flex-col overflow-hidden dark:bg-dark-900 ${asideStyle} ${corners}`}
|
||||
>
|
||||
<div
|
||||
className={`flex flex-row h-14 items-center justify-between ${asideBrandStyle}`}
|
||||
>
|
||||
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">
|
||||
|
||||
<b className="font-black">DakikTabela Ops Suite</b>
|
||||
|
||||
|
||||
{organizationName && <p>{organizationName}</p>}
|
||||
|
||||
<div className={`flex-1 flex flex-col overflow-hidden dark:bg-dark-900 ${asideStyle} ${corners}`}>
|
||||
<div className={`flex min-h-14 items-center justify-between gap-3 px-4 py-3 ${asideBrandStyle}`}>
|
||||
<div className='flex min-w-0 items-center gap-3'>
|
||||
<Logo className='h-10 w-auto shrink-0 rounded-xl bg-white p-1.5 shadow-sm' />
|
||||
<div className='min-w-0'>
|
||||
<p className='truncate text-sm font-semibold text-slate-900 dark:text-white'>DakikTabela</p>
|
||||
<p className='truncate text-xs text-slate-500 dark:text-slate-300'>Operasyon Platformu</p>
|
||||
{organizationName && (
|
||||
<p className='truncate text-[11px] text-slate-400 dark:text-slate-400'>{organizationName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="hidden lg:inline-block xl:hidden p-3"
|
||||
onClick={handleAsideLgCloseClick}
|
||||
>
|
||||
<button className='hidden lg:inline-block xl:hidden p-3' onClick={handleAsideLgCloseClick}>
|
||||
<BaseIcon path={mdiClose} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -10,25 +10,16 @@ export default function FooterBar({ children }: Props) {
|
||||
const year = new Date().getFullYear()
|
||||
|
||||
return (
|
||||
<footer className={`py-2 px-6 ${containerMaxW}`}>
|
||||
<div className="block md:flex items-center justify-between">
|
||||
<div className="text-center md:text-left mb-6 md:mb-0">
|
||||
<b>
|
||||
©{year},{` `}
|
||||
<a href="https://flatlogic.com/" rel="noreferrer" target="_blank">
|
||||
Flatlogic
|
||||
</a>
|
||||
.
|
||||
</b>
|
||||
{` `}
|
||||
{children}
|
||||
<footer className={`py-3 px-6 ${containerMaxW}`}>
|
||||
<div className="block md:flex items-center justify-between gap-4">
|
||||
<div className="text-center md:text-left mb-4 md:mb-0 text-sm text-slate-600 dark:text-slate-300">
|
||||
<b className='text-slate-900 dark:text-white'>© {year} DakikTabela.</b>{' '}
|
||||
{children || 'Satış, üretim ve etkinlik operasyonları tek platformda.'}
|
||||
</div>
|
||||
|
||||
<div className="flex item-center md:py-2 gap-4">
|
||||
<a href="https://flatlogic.com/" rel="noreferrer" target="_blank">
|
||||
<Logo className="w-auto h-8 md:h-6 mx-auto" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center justify-center md:justify-end gap-3">
|
||||
<Logo className="w-auto h-10 md:h-8" />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
|
||||
@ -1,96 +1,102 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Select, { components, SingleValueProps, OptionProps } from 'react-select';
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import Select, { components, SingleValueProps, OptionProps } from 'react-select'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type LanguageOption = { label: string; value: string };
|
||||
type LanguageOption = { label: string; value: string }
|
||||
|
||||
const LANGS: LanguageOption[] = [
|
||||
{ value: 'en', label: '🇬🇧 EN' },
|
||||
{ value: 'fr', label: '🇫🇷 FR' },
|
||||
{ value: 'es', label: '🇪🇸 ES' },
|
||||
{ value: 'de', label: '🇩🇪 DE' },
|
||||
];
|
||||
{ value: 'tr', label: '🇹🇷 TR' },
|
||||
{ value: 'en', label: '🇬🇧 EN' },
|
||||
{ value: 'fr', label: '🇫🇷 FR' },
|
||||
{ value: 'es', label: '🇪🇸 ES' },
|
||||
{ value: 'de', label: '🇩🇪 DE' },
|
||||
]
|
||||
|
||||
const Option = (props: OptionProps<LanguageOption, false>) => (
|
||||
<components.Option {...props}>
|
||||
<span className='flex items-center gap-1'>{props.data.label}</span>
|
||||
</components.Option>
|
||||
);
|
||||
<components.Option {...props}>
|
||||
<span className='flex items-center gap-1'>{props.data.label}</span>
|
||||
</components.Option>
|
||||
)
|
||||
|
||||
const SingleVal = (props: SingleValueProps<LanguageOption, false>) => (
|
||||
<components.SingleValue {...props}>
|
||||
<span className='flex items-center gap-1'>{props.data.label}</span>
|
||||
</components.SingleValue>
|
||||
);
|
||||
<components.SingleValue {...props}>
|
||||
<span className='flex items-center gap-1'>{props.data.label}</span>
|
||||
</components.SingleValue>
|
||||
)
|
||||
|
||||
const LanguageSwitcher: React.FC = () => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [selected, setSelected] = useState<LanguageOption>(LANGS[0]);
|
||||
const { i18n } = useTranslation()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [selected, setSelected] = useState<LanguageOption>(LANGS[0])
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const active = LANGS.find((item) => item.value === i18n.language) || LANGS[0]
|
||||
setSelected(active)
|
||||
setMounted(true)
|
||||
}, [i18n.language])
|
||||
|
||||
const handleChange = (opt: LanguageOption | null) => {
|
||||
if (!opt) return;
|
||||
setSelected(opt);
|
||||
};
|
||||
const handleChange = (opt: LanguageOption | null) => {
|
||||
if (!opt) return
|
||||
setSelected(opt)
|
||||
i18n.changeLanguage(opt.value)
|
||||
}
|
||||
|
||||
if (!mounted) return null;
|
||||
if (!mounted) return null
|
||||
|
||||
return (
|
||||
<div style={{ width: 88 }}>
|
||||
<Select
|
||||
value={selected}
|
||||
options={LANGS}
|
||||
onChange={handleChange}
|
||||
isSearchable={false}
|
||||
menuPlacement='top'
|
||||
components={{
|
||||
Option,
|
||||
SingleValue: SingleVal,
|
||||
IndicatorSeparator: () => null,
|
||||
}}
|
||||
styles={{
|
||||
control: (base) => ({
|
||||
...base,
|
||||
minHeight: 28,
|
||||
height: 28,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
borderColor: '#d1d5db',
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
valueContainer: (base) => ({
|
||||
...base,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
paddingLeft: 6,
|
||||
}),
|
||||
indicatorsContainer: (base) => ({
|
||||
...base,
|
||||
height: 24,
|
||||
}),
|
||||
dropdownIndicator: (base) => ({
|
||||
...base,
|
||||
padding: 2,
|
||||
}),
|
||||
option: (base, state) => ({
|
||||
...base,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 4,
|
||||
height: 26,
|
||||
fontSize: '0.875rem',
|
||||
backgroundColor: state.isFocused ? '#f3f4f6' : 'white',
|
||||
color: '#111827',
|
||||
}),
|
||||
menu: (base) => ({
|
||||
...base,
|
||||
zIndex: 9999,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div style={{ width: 88 }}>
|
||||
<Select
|
||||
value={selected}
|
||||
options={LANGS}
|
||||
onChange={handleChange}
|
||||
isSearchable={false}
|
||||
menuPlacement='top'
|
||||
components={{
|
||||
Option,
|
||||
SingleValue: SingleVal,
|
||||
IndicatorSeparator: () => null,
|
||||
}}
|
||||
styles={{
|
||||
control: (base) => ({
|
||||
...base,
|
||||
minHeight: 28,
|
||||
height: 28,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
borderColor: '#d1d5db',
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
valueContainer: (base) => ({
|
||||
...base,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
paddingLeft: 6,
|
||||
}),
|
||||
indicatorsContainer: (base) => ({
|
||||
...base,
|
||||
height: 24,
|
||||
}),
|
||||
dropdownIndicator: (base) => ({
|
||||
...base,
|
||||
padding: 2,
|
||||
}),
|
||||
option: (base, state) => ({
|
||||
...base,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 4,
|
||||
height: 26,
|
||||
fontSize: '0.875rem',
|
||||
backgroundColor: state.isFocused ? '#f3f4f6' : 'white',
|
||||
color: '#111827',
|
||||
}),
|
||||
menu: (base) => ({
|
||||
...base,
|
||||
zIndex: 9999,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LanguageSwitcher;
|
||||
export default LanguageSwitcher
|
||||
|
||||
@ -7,9 +7,9 @@ type Props = {
|
||||
export default function Logo({ className = '' }: Props) {
|
||||
return (
|
||||
<img
|
||||
src={"https://flatlogic.com/logo.svg"}
|
||||
src='/brand/dakiktabela-logo.svg'
|
||||
className={className}
|
||||
alt={'Flatlogic logo'}>
|
||||
</img>
|
||||
alt='DakikTabela logosu'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import React, {useEffect, useRef} from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||
import BaseDivider from './BaseDivider'
|
||||
import BaseIcon from './BaseIcon'
|
||||
@ -67,8 +66,10 @@ export default function NavBarItem({ item }: Props) {
|
||||
const getItemId = (label) => {
|
||||
switch (label) {
|
||||
case 'Light/Dark':
|
||||
case 'Açık/Koyu Tema':
|
||||
return 'themeToggle';
|
||||
case 'Log out':
|
||||
case 'Çıkış yap':
|
||||
return 'logout';
|
||||
default:
|
||||
return undefined;
|
||||
|
||||
@ -49,16 +49,16 @@ export default function PasswordSetOrReset() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
{isInvitation && <title>{getPageTitle('Set Password')}</title>}
|
||||
{!isInvitation && <title>{getPageTitle('Reset Password')}</title>}
|
||||
{isInvitation && <title>{getPageTitle('Şifre Belirle')}</title>}
|
||||
{!isInvitation && <title>{getPageTitle('Şifreyi Sıfırla')}</title>}
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div className='w-full flex flex-col items-center justify-center'>
|
||||
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
|
||||
{isInvitation && <p className='text-xl mb-2'>Set Password</p>}
|
||||
{!isInvitation && <p className='text-xl mb-2'>Reset Password</p>}
|
||||
<p className='text-base mb-4'>Enter your new password</p>
|
||||
{isInvitation && <p className='text-xl mb-2'>Şifre Belirle</p>}
|
||||
{!isInvitation && <p className='text-xl mb-2'>Şifreyi Sıfırla</p>}
|
||||
<p className='text-base mb-4'>Yeni şifrenizi girin</p>
|
||||
|
||||
<Formik
|
||||
initialValues={{
|
||||
@ -74,7 +74,7 @@ export default function PasswordSetOrReset() {
|
||||
<Field
|
||||
type='password'
|
||||
name='password'
|
||||
placeholder='Password'
|
||||
placeholder='Şifre'
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
@ -82,7 +82,7 @@ export default function PasswordSetOrReset() {
|
||||
<Field
|
||||
type='password'
|
||||
name='confirm'
|
||||
placeholder='Confirm Password'
|
||||
placeholder='Şifre Tekrarı'
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@ -93,10 +93,10 @@ export default function PasswordSetOrReset() {
|
||||
disabled={loading}
|
||||
label={
|
||||
loading
|
||||
? 'Loading...'
|
||||
? 'Yükleniyor...'
|
||||
: isInvitation
|
||||
? 'Set Password'
|
||||
: 'Reset Password'
|
||||
? 'Şifre Belirle'
|
||||
: 'Şifreyi Sıfırla'
|
||||
}
|
||||
color='info'
|
||||
/>
|
||||
|
||||
@ -8,7 +8,7 @@ const SearchResults = ({ searchResults, searchQuery }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className={'block font-bold mb-2'}>Matches with: {searchQuery}</p>
|
||||
<p className={'block font-bold mb-2'}>Aranan ifade: {searchQuery}</p>
|
||||
{Object.keys(searchResults).map((tableName) => (
|
||||
<>
|
||||
<p className={'block font-bold mb-2'}>{humanize(tableName)}</p>
|
||||
@ -68,13 +68,13 @@ const SearchResults = ({ searchResults, searchQuery }) => {
|
||||
</table>
|
||||
</div>
|
||||
{!Object.keys(searchResults).length && (
|
||||
<div className={'text-center py-4'}>No data</div>
|
||||
<div className={'text-center py-4'}>Veri bulunamadı</div>
|
||||
)}
|
||||
</CardBox>
|
||||
</>
|
||||
))}
|
||||
{!Object.keys(searchResults).length && (
|
||||
<div className={'py-4'}>No matches</div>
|
||||
<div className={'py-4'}>Eşleşme bulunamadı</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@ -75,7 +75,7 @@ export const SmartWidget = ({ widget, userId, admin, roleId }) => {
|
||||
)
|
||||
) : (
|
||||
<div className='text-center text-red-400'>
|
||||
Something went wrong, please try again or use a different query.
|
||||
Bir sorun oluştu. Lütfen tekrar deneyin veya farklı bir sorgu kullanın.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -67,7 +67,7 @@ export const WidgetCreator = ({
|
||||
const errorMessage =
|
||||
responcePayload.data?.error?.message || error?.message;
|
||||
await dispatch(
|
||||
setErrorNotification(errorMessage || 'Error with widget creation'),
|
||||
setErrorNotification(errorMessage || 'Bileşen oluşturulurken bir hata oluştu'),
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -90,11 +90,11 @@ export const WidgetCreator = ({
|
||||
>
|
||||
<Form>
|
||||
<FormField
|
||||
label='Create Chart or Widget'
|
||||
label='Grafik veya bileşen oluştur'
|
||||
help={
|
||||
isFetchingQuery ?
|
||||
'Loading...' :
|
||||
'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"'
|
||||
'Yükleniyor...' :
|
||||
'Yeni bileşeni doğal dille tarif edin. Örnek: "Aylara göre kapanan fırsat sayısı" veya "Yönetici kullanıcı sayısı"'
|
||||
}
|
||||
>
|
||||
<Field type='input' name='description' disabled={isFetchingQuery} />
|
||||
@ -110,14 +110,14 @@ export const WidgetCreator = ({
|
||||
>
|
||||
{({ submitForm }) => (
|
||||
<CardBoxModal
|
||||
title='Widget Creator Settings'
|
||||
title='Bileşen oluşturucu ayarları'
|
||||
buttonColor='info'
|
||||
buttonLabel='Done'
|
||||
buttonLabel='Tamam'
|
||||
isActive={isModalOpen}
|
||||
onConfirm={submitForm}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
>
|
||||
<p>What role are we showing and creating widgets for?</p>
|
||||
<p>Bileşenleri hangi rol için göstereceğimizi ve oluşturacağımızı seçin.</p>
|
||||
|
||||
<Form>
|
||||
<FormField>
|
||||
|
||||
@ -8,7 +8,7 @@ export const localStorageStyleKey = 'style'
|
||||
|
||||
export const containerMaxW = 'xl:max-w-full xl:mx-auto 2xl:mx-20'
|
||||
|
||||
export const appTitle = 'created by Flatlogic generator!'
|
||||
export const appTitle = 'DakikTabela Operasyon Platformu'
|
||||
|
||||
export const getPageTitle = (currentPageTitle: string) => `${currentPageTitle} — ${appTitle}`
|
||||
|
||||
|
||||
@ -33,3 +33,8 @@
|
||||
.introjs-prevbutton{
|
||||
@apply bg-transparent border border-blue-600 text-blue-600 !important;
|
||||
}
|
||||
|
||||
|
||||
html, body {
|
||||
font-family: 'Poppins', ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: 'en',
|
||||
fallbackLng: 'tr',
|
||||
detection: {
|
||||
order: ['localStorage', 'navigator'],
|
||||
lookupLocalStorage: 'app_lang_',
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import React, { ReactNode, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||
import menuAside from '../menuAside'
|
||||
@ -122,7 +121,7 @@ export default function LayoutAuthenticated({
|
||||
onAsideLgClose={() => setIsAsideLgActive(false)}
|
||||
/>
|
||||
{children}
|
||||
<FooterBar>Hand-crafted & Made with ❤️</FooterBar>
|
||||
<FooterBar>Satış, üretim ve etkinlik süreçleri tek ekranda yönetilir.</FooterBar>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,234 +1,212 @@
|
||||
import * as icon from '@mdi/js';
|
||||
import * as icon from '@mdi/js'
|
||||
import { MenuAsideItem } from './interfaces'
|
||||
|
||||
const mdi = icon as Record<string, string>
|
||||
const pickIcon = (name: string, fallback = icon.mdiTable) => mdi[name] || fallback
|
||||
|
||||
const menuAside: MenuAsideItem[] = [
|
||||
{
|
||||
href: '/dashboard',
|
||||
icon: icon.mdiViewDashboardOutline,
|
||||
label: 'Dashboard',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/users/users-list',
|
||||
label: 'Users',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiAccountGroup ?? icon.mdiTable,
|
||||
permissions: 'READ_USERS'
|
||||
label: 'Operasyon Özeti',
|
||||
},
|
||||
{
|
||||
href: '/roles/roles-list',
|
||||
label: 'Roles',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
|
||||
permissions: 'READ_ROLES'
|
||||
href: '/sales-hub',
|
||||
icon: icon.mdiChartTimelineVariant,
|
||||
label: 'Satış Merkezi',
|
||||
},
|
||||
{
|
||||
href: '/permissions/permissions-list',
|
||||
label: 'Permissions',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
|
||||
permissions: 'READ_PERMISSIONS'
|
||||
label: 'CRM ve Satış',
|
||||
icon: pickIcon('mdiHandshake', icon.mdiTable),
|
||||
withDevider: true,
|
||||
menu: [
|
||||
{
|
||||
href: '/companies/companies-list',
|
||||
label: 'Şirketler',
|
||||
icon: pickIcon('mdiDomain'),
|
||||
permissions: 'READ_COMPANIES',
|
||||
},
|
||||
{
|
||||
href: '/contacts/contacts-list',
|
||||
label: 'Kişiler',
|
||||
icon: pickIcon('mdiCardAccountDetails'),
|
||||
permissions: 'READ_CONTACTS',
|
||||
},
|
||||
{
|
||||
href: '/pipelines/pipelines-list',
|
||||
label: 'Pipeline\'lar',
|
||||
icon: pickIcon('mdiSourceBranch'),
|
||||
permissions: 'READ_PIPELINES',
|
||||
},
|
||||
{
|
||||
href: '/pipeline_stages/pipeline_stages-list',
|
||||
label: 'Aşamalar',
|
||||
icon: pickIcon('mdiSwapHorizontal'),
|
||||
permissions: 'READ_PIPELINE_STAGES',
|
||||
},
|
||||
{
|
||||
href: '/leads/leads-list',
|
||||
label: 'Potansiyel Müşteriler',
|
||||
icon: pickIcon('mdiAccountArrowRight'),
|
||||
permissions: 'READ_LEADS',
|
||||
},
|
||||
{
|
||||
href: '/deals/deals-list',
|
||||
label: 'Fırsatlar',
|
||||
icon: pickIcon('mdiHandshake'),
|
||||
permissions: 'READ_DEALS',
|
||||
},
|
||||
{
|
||||
href: '/activities/activities-list',
|
||||
label: 'Aktiviteler',
|
||||
icon: pickIcon('mdiCalendarCheck'),
|
||||
permissions: 'READ_ACTIVITIES',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
href: '/organizations/organizations-list',
|
||||
label: 'Organizations',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_ORGANIZATIONS'
|
||||
label: 'Üretim ERP',
|
||||
icon: pickIcon('mdiFactory', icon.mdiTable),
|
||||
menu: [
|
||||
{
|
||||
href: '/materials/materials-list',
|
||||
label: 'Malzemeler',
|
||||
icon: pickIcon('mdiCubeOutline'),
|
||||
permissions: 'READ_MATERIALS',
|
||||
},
|
||||
{
|
||||
href: '/warehouses/warehouses-list',
|
||||
label: 'Depolar',
|
||||
icon: pickIcon('mdiWarehouse'),
|
||||
permissions: 'READ_WAREHOUSES',
|
||||
},
|
||||
{
|
||||
href: '/inventory_items/inventory_items-list',
|
||||
label: 'Stok Kalemleri',
|
||||
icon: pickIcon('mdiPackageVariantClosed'),
|
||||
permissions: 'READ_INVENTORY_ITEMS',
|
||||
},
|
||||
{
|
||||
href: '/machines/machines-list',
|
||||
label: 'Makineler',
|
||||
icon: pickIcon('mdiRobotIndustrial'),
|
||||
permissions: 'READ_MACHINES',
|
||||
},
|
||||
{
|
||||
href: '/production_orders/production_orders-list',
|
||||
label: 'Üretim Emirleri',
|
||||
icon: pickIcon('mdiFactory'),
|
||||
permissions: 'READ_PRODUCTION_ORDERS',
|
||||
},
|
||||
{
|
||||
href: '/production_operations/production_operations-list',
|
||||
label: 'Operasyonlar',
|
||||
icon: pickIcon('mdiPlaylistPlay'),
|
||||
permissions: 'READ_PRODUCTION_OPERATIONS',
|
||||
},
|
||||
{
|
||||
href: '/quality_inspections/quality_inspections-list',
|
||||
label: 'Kalite Kontrolleri',
|
||||
icon: pickIcon('mdiClipboardSearch'),
|
||||
permissions: 'READ_QUALITY_INSPECTIONS',
|
||||
},
|
||||
{
|
||||
href: '/nonconformances/nonconformances-list',
|
||||
label: 'Uygunsuzluklar',
|
||||
icon: pickIcon('mdiAlertOctagonOutline'),
|
||||
permissions: 'READ_NONCONFORMANCES',
|
||||
},
|
||||
{
|
||||
href: '/vendors/vendors-list',
|
||||
label: 'Tedarikçiler',
|
||||
icon: pickIcon('mdiTruckFast'),
|
||||
permissions: 'READ_VENDORS',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
href: '/companies/companies-list',
|
||||
label: 'Companies',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiDomain' in icon ? icon['mdiDomain' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_COMPANIES'
|
||||
label: 'Etkinlik Yönetimi',
|
||||
icon: pickIcon('mdiCalendarStar', icon.mdiTable),
|
||||
menu: [
|
||||
{
|
||||
href: '/venues/venues-list',
|
||||
label: 'Mekanlar',
|
||||
icon: pickIcon('mdiMapMarkerRadius'),
|
||||
permissions: 'READ_VENUES',
|
||||
},
|
||||
{
|
||||
href: '/events/events-list',
|
||||
label: 'Etkinlikler',
|
||||
icon: pickIcon('mdiCalendarStar'),
|
||||
permissions: 'READ_EVENTS',
|
||||
},
|
||||
{
|
||||
href: '/event_tasks/event_tasks-list',
|
||||
label: 'Etkinlik Görevleri',
|
||||
icon: pickIcon('mdiChecklist'),
|
||||
permissions: 'READ_EVENT_TASKS',
|
||||
},
|
||||
{
|
||||
href: '/event_guests/event_guests-list',
|
||||
label: 'Davetliler',
|
||||
icon: pickIcon('mdiAccountGroup'),
|
||||
permissions: 'READ_EVENT_GUESTS',
|
||||
},
|
||||
{
|
||||
href: '/event_schedule_items/event_schedule_items-list',
|
||||
label: 'Takvim Kalemleri',
|
||||
icon: pickIcon('mdiTimelineText'),
|
||||
permissions: 'READ_EVENT_SCHEDULE_ITEMS',
|
||||
},
|
||||
{
|
||||
href: '/budget_items/budget_items-list',
|
||||
label: 'Bütçe Kalemleri',
|
||||
icon: pickIcon('mdiCurrencyUsd'),
|
||||
permissions: 'READ_BUDGET_ITEMS',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
href: '/contacts/contacts-list',
|
||||
label: 'Contacts',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiCardAccountDetails' in icon ? icon['mdiCardAccountDetails' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_CONTACTS'
|
||||
},
|
||||
{
|
||||
href: '/pipelines/pipelines-list',
|
||||
label: 'Pipelines',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiSourceBranch' in icon ? icon['mdiSourceBranch' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_PIPELINES'
|
||||
},
|
||||
{
|
||||
href: '/pipeline_stages/pipeline_stages-list',
|
||||
label: 'Pipeline stages',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiSwapHorizontal' in icon ? icon['mdiSwapHorizontal' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_PIPELINE_STAGES'
|
||||
},
|
||||
{
|
||||
href: '/leads/leads-list',
|
||||
label: 'Leads',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiAccountArrowRight' in icon ? icon['mdiAccountArrowRight' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_LEADS'
|
||||
},
|
||||
{
|
||||
href: '/deals/deals-list',
|
||||
label: 'Deals',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiHandshake' in icon ? icon['mdiHandshake' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_DEALS'
|
||||
},
|
||||
{
|
||||
href: '/activities/activities-list',
|
||||
label: 'Activities',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiCalendarCheck' in icon ? icon['mdiCalendarCheck' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_ACTIVITIES'
|
||||
},
|
||||
{
|
||||
href: '/materials/materials-list',
|
||||
label: 'Materials',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiCubeOutline' in icon ? icon['mdiCubeOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_MATERIALS'
|
||||
},
|
||||
{
|
||||
href: '/warehouses/warehouses-list',
|
||||
label: 'Warehouses',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiWarehouse' in icon ? icon['mdiWarehouse' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_WAREHOUSES'
|
||||
},
|
||||
{
|
||||
href: '/inventory_items/inventory_items-list',
|
||||
label: 'Inventory items',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiPackageVariantClosed' in icon ? icon['mdiPackageVariantClosed' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_INVENTORY_ITEMS'
|
||||
},
|
||||
{
|
||||
href: '/machines/machines-list',
|
||||
label: 'Machines',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiRobotIndustrial' in icon ? icon['mdiRobotIndustrial' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_MACHINES'
|
||||
},
|
||||
{
|
||||
href: '/production_orders/production_orders-list',
|
||||
label: 'Production orders',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiFactory' in icon ? icon['mdiFactory' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_PRODUCTION_ORDERS'
|
||||
},
|
||||
{
|
||||
href: '/production_operations/production_operations-list',
|
||||
label: 'Production operations',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiPlaylistPlay' in icon ? icon['mdiPlaylistPlay' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_PRODUCTION_OPERATIONS'
|
||||
},
|
||||
{
|
||||
href: '/quality_inspections/quality_inspections-list',
|
||||
label: 'Quality inspections',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiClipboardSearch' in icon ? icon['mdiClipboardSearch' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_QUALITY_INSPECTIONS'
|
||||
},
|
||||
{
|
||||
href: '/nonconformances/nonconformances-list',
|
||||
label: 'Nonconformances',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiAlertOctagonOutline' in icon ? icon['mdiAlertOctagonOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_NONCONFORMANCES'
|
||||
},
|
||||
{
|
||||
href: '/vendors/vendors-list',
|
||||
label: 'Vendors',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiTruckFast' in icon ? icon['mdiTruckFast' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_VENDORS'
|
||||
},
|
||||
{
|
||||
href: '/venues/venues-list',
|
||||
label: 'Venues',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiMapMarkerRadius' in icon ? icon['mdiMapMarkerRadius' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_VENUES'
|
||||
},
|
||||
{
|
||||
href: '/events/events-list',
|
||||
label: 'Events',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiCalendarStar' in icon ? icon['mdiCalendarStar' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_EVENTS'
|
||||
},
|
||||
{
|
||||
href: '/event_tasks/event_tasks-list',
|
||||
label: 'Event tasks',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiChecklist' in icon ? icon['mdiChecklist' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_EVENT_TASKS'
|
||||
},
|
||||
{
|
||||
href: '/event_guests/event_guests-list',
|
||||
label: 'Event guests',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiAccountGroup' in icon ? icon['mdiAccountGroup' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_EVENT_GUESTS'
|
||||
},
|
||||
{
|
||||
href: '/event_schedule_items/event_schedule_items-list',
|
||||
label: 'Event schedule items',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiTimelineText' in icon ? icon['mdiTimelineText' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_EVENT_SCHEDULE_ITEMS'
|
||||
},
|
||||
{
|
||||
href: '/budget_items/budget_items-list',
|
||||
label: 'Budget items',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiCurrencyUsd' in icon ? icon['mdiCurrencyUsd' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_BUDGET_ITEMS'
|
||||
},
|
||||
{
|
||||
href: '/profile',
|
||||
label: 'Profile',
|
||||
icon: icon.mdiAccountCircle,
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
href: '/api-docs',
|
||||
target: '_blank',
|
||||
label: 'Swagger API',
|
||||
icon: icon.mdiFileCode,
|
||||
permissions: 'READ_API_DOCS'
|
||||
label: 'Yönetim',
|
||||
icon: icon.mdiShieldAccountVariantOutline,
|
||||
menu: [
|
||||
{
|
||||
href: '/users/users-list',
|
||||
label: 'Kullanıcılar',
|
||||
icon: icon.mdiAccountGroup,
|
||||
permissions: 'READ_USERS',
|
||||
},
|
||||
{
|
||||
href: '/roles/roles-list',
|
||||
label: 'Roller',
|
||||
icon: icon.mdiShieldAccountVariantOutline,
|
||||
permissions: 'READ_ROLES',
|
||||
},
|
||||
{
|
||||
href: '/permissions/permissions-list',
|
||||
label: 'Yetkiler',
|
||||
icon: icon.mdiShieldAccountOutline,
|
||||
permissions: 'READ_PERMISSIONS',
|
||||
},
|
||||
{
|
||||
href: '/organizations/organizations-list',
|
||||
label: 'Organizasyonlar',
|
||||
icon: icon.mdiTable,
|
||||
permissions: 'READ_ORGANIZATIONS',
|
||||
},
|
||||
{
|
||||
href: '/profile',
|
||||
label: 'Profil',
|
||||
icon: icon.mdiAccountCircle,
|
||||
},
|
||||
{
|
||||
href: '/api-docs',
|
||||
target: '_blank',
|
||||
label: 'Swagger API',
|
||||
icon: icon.mdiFileCode,
|
||||
permissions: 'READ_API_DOCS',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@ -1,15 +1,7 @@
|
||||
import {
|
||||
mdiMenu,
|
||||
mdiClockOutline,
|
||||
mdiCloud,
|
||||
mdiCrop,
|
||||
mdiAccount,
|
||||
mdiCogOutline,
|
||||
mdiEmail,
|
||||
mdiLogout,
|
||||
mdiThemeLightDark,
|
||||
mdiGithub,
|
||||
mdiVuejs,
|
||||
} from '@mdi/js'
|
||||
import { MenuNavBarItem } from './interfaces'
|
||||
|
||||
@ -19,7 +11,7 @@ const menuNavBar: MenuNavBarItem[] = [
|
||||
menu: [
|
||||
{
|
||||
icon: mdiAccount,
|
||||
label: 'My Profile',
|
||||
label: 'Profilim',
|
||||
href: '/profile',
|
||||
},
|
||||
{
|
||||
@ -27,27 +19,25 @@ const menuNavBar: MenuNavBarItem[] = [
|
||||
},
|
||||
{
|
||||
icon: mdiLogout,
|
||||
label: 'Log Out',
|
||||
label: 'Çıkış yap',
|
||||
isLogout: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: mdiThemeLightDark,
|
||||
label: 'Light/Dark',
|
||||
label: 'Açık/Koyu Tema',
|
||||
isDesktopNoLabel: true,
|
||||
isToggleLightDark: true,
|
||||
},
|
||||
{
|
||||
icon: mdiLogout,
|
||||
label: 'Log out',
|
||||
label: 'Çıkış yap',
|
||||
isDesktopNoLabel: true,
|
||||
isLogout: true,
|
||||
},
|
||||
]
|
||||
|
||||
export const webPagesNavBar = [
|
||||
|
||||
];
|
||||
export const webPagesNavBar = []
|
||||
|
||||
export default menuNavBar
|
||||
|
||||
@ -3,24 +3,30 @@ import type { AppProps } from 'next/app';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import type { NextPage } from 'next';
|
||||
import Head from 'next/head';
|
||||
import { Poppins } from 'next/font/google';
|
||||
import { store } from '../stores/store';
|
||||
import { Provider } from 'react-redux';
|
||||
import '../css/main.css';
|
||||
import axios from 'axios';
|
||||
import { baseURLApi } from '../config';
|
||||
import { useRouter } from 'next/router';
|
||||
import ErrorBoundary from "../components/ErrorBoundary";
|
||||
import ErrorBoundary from '../components/ErrorBoundary';
|
||||
import DevModeBadge from '../components/DevModeBadge';
|
||||
import 'intro.js/introjs.css';
|
||||
import { appWithTranslation } from 'next-i18next';
|
||||
import '../i18n';
|
||||
import IntroGuide from '../components/IntroGuide';
|
||||
import { appSteps, loginSteps, usersSteps, rolesSteps } from '../stores/introSteps';
|
||||
import { appSteps, loginSteps, usersSteps, rolesSteps } from '../stores/introSteps';
|
||||
|
||||
const poppins = Poppins({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '600', '700'],
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
// Initialize axios
|
||||
axios.defaults.baseURL = process.env.NEXT_PUBLIC_BACK_API
|
||||
? process.env.NEXT_PUBLIC_BACK_API
|
||||
: baseURLApi;
|
||||
? process.env.NEXT_PUBLIC_BACK_API
|
||||
: baseURLApi;
|
||||
|
||||
axios.defaults.headers.common['Content-Type'] = 'application/json';
|
||||
|
||||
@ -33,115 +39,114 @@ type AppPropsWithLayout = AppProps & {
|
||||
}
|
||||
|
||||
function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
// Use the layout defined at the page level, if available
|
||||
const getLayout = Component.getLayout || ((page) => page);
|
||||
const router = useRouter();
|
||||
const [stepsEnabled, setStepsEnabled] = React.useState(false);
|
||||
const [stepName, setStepName] = React.useState('');
|
||||
const [steps, setSteps] = React.useState([]);
|
||||
|
||||
axios.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('token');
|
||||
React.useEffect(() => {
|
||||
document.documentElement.lang = 'tr';
|
||||
}, []);
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
} else {
|
||||
delete config.headers.Authorization;
|
||||
}
|
||||
axios.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
return config;
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
} else {
|
||||
delete config.headers.Authorization;
|
||||
}
|
||||
|
||||
// TODO: Remove this code in future releases
|
||||
React.useEffect(() => {
|
||||
const allowedOrigin = (() => {
|
||||
if (!document.referrer) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new URL(document.referrer).origin;
|
||||
} catch (error) {
|
||||
console.warn('[postMessage] Failed to parse parent origin from referrer', error);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const handleMessage = async (event: MessageEvent) => {
|
||||
if (event.data === 'getLocation') {
|
||||
event.source?.postMessage(
|
||||
{ iframeLocation: window.location.pathname },
|
||||
event.origin,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data === 'getAuthToken') {
|
||||
if (allowedOrigin && event.origin !== allowedOrigin) {
|
||||
console.warn('[postMessage] Blocked getAuthToken from origin', event.origin);
|
||||
return;
|
||||
}
|
||||
const token = localStorage.getItem('token');
|
||||
const user = localStorage.getItem('user');
|
||||
event.source?.postMessage(
|
||||
{ iframeAuthToken: token, iframeAuthUser: user },
|
||||
event.origin,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data === 'getScreenshot') {
|
||||
try {
|
||||
const html2canvas = (await import('html2canvas')).default;
|
||||
const canvas = await html2canvas(document.body, { useCORS: true });
|
||||
const url = canvas.toDataURL('image/jpeg', 0.8);
|
||||
event.source?.postMessage({ iframeScreenshot: url }, event.origin);
|
||||
} catch (e) {
|
||||
console.error('html2canvas failed', e);
|
||||
event.source?.postMessage({ iframeScreenshot: null }, event.origin);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, []);
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const allowedOrigin = (() => {
|
||||
if (!document.referrer) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new URL(document.referrer).origin;
|
||||
} catch (error) {
|
||||
console.warn('[postMessage] Failed to parse parent origin from referrer', error);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const handleMessage = async (event: MessageEvent) => {
|
||||
if (event.data === 'getLocation') {
|
||||
event.source?.postMessage(
|
||||
{ iframeLocation: window.location.pathname },
|
||||
event.origin,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data === 'getAuthToken') {
|
||||
if (allowedOrigin && event.origin !== allowedOrigin) {
|
||||
console.warn('[postMessage] Blocked getAuthToken from origin', event.origin);
|
||||
return;
|
||||
}
|
||||
const token = localStorage.getItem('token');
|
||||
const user = localStorage.getItem('user');
|
||||
event.source?.postMessage(
|
||||
{ iframeAuthToken: token, iframeAuthUser: user },
|
||||
event.origin,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data === 'getScreenshot') {
|
||||
try {
|
||||
const html2canvas = (await import('html2canvas')).default;
|
||||
const canvas = await html2canvas(document.body, { useCORS: true });
|
||||
const url = canvas.toDataURL('image/jpeg', 0.8);
|
||||
event.source?.postMessage({ iframeScreenshot: url }, event.origin);
|
||||
} catch (e) {
|
||||
console.error('html2canvas failed', e);
|
||||
event.source?.postMessage({ iframeScreenshot: null }, event.origin);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Tour is disabled by default in generated projects.
|
||||
return;
|
||||
const isCompleted = (stepKey: string) => {
|
||||
return localStorage.getItem(`completed_${stepKey}`) === 'true';
|
||||
};
|
||||
if (router.pathname === '/login' && !isCompleted('loginSteps')) {
|
||||
if (router.pathname === '/login' && !isCompleted('loginSteps')) {
|
||||
setSteps(loginSteps);
|
||||
setStepName('loginSteps');
|
||||
setStepsEnabled(true);
|
||||
}else if (router.pathname === '/dashboard' && !isCompleted('appSteps')) {
|
||||
setStepsEnabled(true);
|
||||
} else if (router.pathname === '/dashboard' && !isCompleted('appSteps')) {
|
||||
setTimeout(() => {
|
||||
setSteps(appSteps);
|
||||
setStepName('appSteps');
|
||||
setStepsEnabled(true);
|
||||
setStepsEnabled(true);
|
||||
}, 1000);
|
||||
} else if (router.pathname === '/users/users-list' && !isCompleted('usersSteps')) {
|
||||
setTimeout(() => {
|
||||
setSteps(usersSteps);
|
||||
setStepName('usersSteps');
|
||||
setStepsEnabled(true);
|
||||
setStepsEnabled(true);
|
||||
}, 1000);
|
||||
} else if (router.pathname === '/roles/roles-list' && !isCompleted('rolesSteps')) {
|
||||
setTimeout(() => {
|
||||
setSteps(rolesSteps);
|
||||
setStepName('rolesSteps');
|
||||
setStepsEnabled(true);
|
||||
setStepsEnabled(true);
|
||||
}, 1000);
|
||||
} else {
|
||||
setSteps([]);
|
||||
setStepsEnabled(false);
|
||||
setStepsEnabled(false);
|
||||
}
|
||||
}, [router.pathname]);
|
||||
|
||||
@ -149,51 +154,56 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
setStepsEnabled(false);
|
||||
};
|
||||
|
||||
const title = 'DakikTabela Ops Suite'
|
||||
const description = "Unified ops suite for CRM pipelines, manufacturing ERP tracking, and event planning coordination with ownership and follow-ups."
|
||||
const url = "https://flatlogic.com/"
|
||||
const image = "https://project-screens.s3.amazonaws.com/screenshots/39422/app-hero-20260331-214356.png"
|
||||
const imageWidth = '1920'
|
||||
const imageHeight = '960'
|
||||
const title = 'DakikTabela Operasyon Platformu';
|
||||
const description =
|
||||
'DakikTabela için CRM, üretim ERP ve etkinlik operasyonlarını tek merkezde yöneten Türkçe iş platformu.';
|
||||
const url = 'https://flatlogic.com/';
|
||||
const image = 'https://project-screens.s3.amazonaws.com/screenshots/39422/app-hero-20260331-214356.png';
|
||||
const imageWidth = '1920';
|
||||
const imageHeight = '960';
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
{getLayout(
|
||||
<>
|
||||
<Head>
|
||||
<meta name="description" content={description} />
|
||||
<div className={poppins.className}>
|
||||
{getLayout(
|
||||
<>
|
||||
<Head>
|
||||
<meta name='description' content={description} />
|
||||
|
||||
<meta property="og:url" content={url} />
|
||||
<meta property="og:site_name" content="https://flatlogic.com/" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={image} />
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
<meta property="og:image:width" content={imageWidth} />
|
||||
<meta property="og:image:height" content={imageHeight} />
|
||||
<meta property='og:url' content={url} />
|
||||
<meta property='og:site_name' content='https://flatlogic.com/' />
|
||||
<meta property='og:title' content={title} />
|
||||
<meta property='og:description' content={description} />
|
||||
<meta property='og:image' content={image} />
|
||||
<meta property='og:image:type' content='image/png' />
|
||||
<meta property='og:image:width' content={imageWidth} />
|
||||
<meta property='og:image:height' content={imageHeight} />
|
||||
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:title" content={title} />
|
||||
<meta property="twitter:description" content={description} />
|
||||
<meta property="twitter:image:src" content={image} />
|
||||
<meta property="twitter:image:width" content={imageWidth} />
|
||||
<meta property="twitter:image:height" content={imageHeight} />
|
||||
<meta property='twitter:card' content='summary_large_image' />
|
||||
<meta property='twitter:title' content={title} />
|
||||
<meta property='twitter:description' content={description} />
|
||||
<meta property='twitter:image:src' content={image} />
|
||||
<meta property='twitter:image:width' content={imageWidth} />
|
||||
<meta property='twitter:image:height' content={imageHeight} />
|
||||
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
</Head>
|
||||
<link rel='icon' href='/favicon.svg' />
|
||||
</Head>
|
||||
|
||||
<ErrorBoundary>
|
||||
<Component {...pageProps} />
|
||||
</ErrorBoundary>
|
||||
<IntroGuide
|
||||
steps={steps}
|
||||
stepsName={stepName}
|
||||
stepsEnabled={stepsEnabled}
|
||||
onExit={handleExit}
|
||||
/>
|
||||
{(process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'dev_stage') && <DevModeBadge />}
|
||||
</>
|
||||
)}
|
||||
<ErrorBoundary>
|
||||
<Component {...pageProps} />
|
||||
</ErrorBoundary>
|
||||
<IntroGuide
|
||||
steps={steps}
|
||||
stepsName={stepName}
|
||||
stepsEnabled={stepsEnabled}
|
||||
onExit={handleExit}
|
||||
/>
|
||||
{(process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'dev_stage') && (
|
||||
<DevModeBadge />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -38,7 +38,7 @@ export default function Forgot() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Login')}</title>
|
||||
<title>{getPageTitle('Şifremi Unuttum')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
@ -50,7 +50,7 @@ export default function Forgot() {
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
<Form>
|
||||
<FormField label='Email' help='Please enter your email'>
|
||||
<FormField label='E-posta' help='E-posta adresinizi yazın'>
|
||||
<Field name='email' />
|
||||
</FormField>
|
||||
|
||||
@ -59,12 +59,12 @@ export default function Forgot() {
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
type='submit'
|
||||
label={loading ? 'Loading...' : 'Submit' }
|
||||
label={loading ? 'Yükleniyor...' : 'Gönder' }
|
||||
color='info'
|
||||
/>
|
||||
<BaseButton
|
||||
href={'/login'}
|
||||
label={'Login'}
|
||||
label={'Giriş yap'}
|
||||
color='info'
|
||||
/>
|
||||
</BaseButtons>
|
||||
|
||||
@ -1,166 +1,156 @@
|
||||
import { mdiArrowRight, mdiCheckCircleOutline, mdiFactory, mdiHandshake, mdiCalendarStar } from '@mdi/js'
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
import React, { ReactElement } from 'react'
|
||||
import LayoutGuest from '../layouts/Guest'
|
||||
import Logo from '../components/Logo'
|
||||
import { getPageTitle } from '../config'
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import { getPageTitle } from '../config';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
||||
const platformCards = [
|
||||
{
|
||||
title: 'Satış CRM',
|
||||
description: 'Potansiyel müşteri, fırsat, kişi, aktivite ve takip adımlarını tek yerden yönetin.',
|
||||
icon: mdiHandshake,
|
||||
href: '/sales-hub',
|
||||
cta: 'Satış merkezini aç',
|
||||
},
|
||||
{
|
||||
title: 'Üretim ERP',
|
||||
description: 'Malzeme, depo, stok, makine, üretim emri ve kalite operasyonlarını görünür kılın.',
|
||||
icon: mdiFactory,
|
||||
href: '/dashboard',
|
||||
cta: 'ERP görünümünü aç',
|
||||
},
|
||||
{
|
||||
title: 'Etkinlik Yönetimi',
|
||||
description: 'Mekan, tedarikçi, davetli, görev, takvim ve bütçe süreçlerini birlikte koordine edin.',
|
||||
icon: mdiCalendarStar,
|
||||
href: '/dashboard',
|
||||
cta: 'Etkinlik modülünü aç',
|
||||
},
|
||||
]
|
||||
|
||||
const promises = [
|
||||
'Tüm ekipler için Türkçe arayüz ve kurumsal görünüm',
|
||||
'Yetki bazlı erişim, sahiplik ve takip disiplini',
|
||||
'CRM, ERP ve etkinlik süreçleri arasında tek operasyon ekranı',
|
||||
]
|
||||
|
||||
export default function Starter() {
|
||||
const [illustrationImage, setIllustrationImage] = useState({
|
||||
src: undefined,
|
||||
photographer: undefined,
|
||||
photographer_url: undefined,
|
||||
})
|
||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
||||
const [contentType, setContentType] = useState('image');
|
||||
const [contentPosition, setContentPosition] = useState('background');
|
||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||
|
||||
const title = 'DakikTabela Ops Suite'
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const image = await getPexelsImage();
|
||||
const video = await getPexelsVideo();
|
||||
setIllustrationImage(image);
|
||||
setIllustrationVideo(video);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const imageBlock = (image) => (
|
||||
<div
|
||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
||||
style={{
|
||||
backgroundImage: `${
|
||||
image
|
||||
? `url(${image?.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={image?.photographer_url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Photo by {image?.photographer} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const videoBlock = (video) => {
|
||||
if (video?.video_files?.length > 0) {
|
||||
return (
|
||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||
<video
|
||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
>
|
||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={video?.user?.url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Video by {video.user.name} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
};
|
||||
|
||||
export default function IndexPage() {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
contentPosition === 'background'
|
||||
? {
|
||||
backgroundImage: `${
|
||||
illustrationImage
|
||||
? `url(${illustrationImage.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
<title>{getPageTitle('DakikTabela')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<CardBoxComponentTitle title="Welcome to your DakikTabela Ops Suite app!"/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||
</div>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
<main className='min-h-screen bg-[#f8fafc] text-slate-900'>
|
||||
<section className='relative overflow-hidden border-b border-slate-200 bg-[radial-gradient(circle_at_top_left,_rgba(20,184,166,0.16),_transparent_28%),radial-gradient(circle_at_top_right,_rgba(29,78,216,0.18),_transparent_32%),linear-gradient(180deg,_#ffffff,_#eef4ff)]'>
|
||||
<div className='mx-auto flex min-h-screen w-full max-w-7xl flex-col px-6 py-8 lg:px-10'>
|
||||
<header className='flex flex-col gap-4 border-b border-slate-200/80 pb-6 md:flex-row md:items-center md:justify-between'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<Logo className='h-14 w-auto rounded-2xl bg-white p-2 shadow-sm' />
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-cyan-700'>dakiktabela.com</p>
|
||||
<h1 className='mt-1 text-xl font-semibold tracking-tight'>DakikTabela Operasyon Platformu</h1>
|
||||
</div>
|
||||
</div>
|
||||
<nav className='flex flex-wrap items-center gap-3'>
|
||||
<Link
|
||||
href='/login'
|
||||
className='inline-flex items-center justify-center rounded-full border border-slate-300 bg-white px-5 py-2.5 text-sm font-semibold text-slate-900 transition hover:border-cyan-400 hover:text-cyan-700'
|
||||
>
|
||||
Giriş yap
|
||||
</Link>
|
||||
<Link
|
||||
href='/dashboard'
|
||||
className='inline-flex items-center justify-center rounded-full bg-slate-900 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-cyan-600'
|
||||
>
|
||||
Yönetim panelini aç
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div className='grid flex-1 items-center gap-14 py-14 lg:grid-cols-[1.1fr_0.9fr] lg:py-20'>
|
||||
<div className='space-y-8'>
|
||||
<div className='inline-flex items-center rounded-full border border-cyan-200 bg-white/90 px-4 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] text-cyan-800 shadow-sm'>
|
||||
Kurumsal, Türkçe ve operasyon odaklı
|
||||
</div>
|
||||
|
||||
<div className='space-y-5'>
|
||||
<h2 className='max-w-4xl text-4xl font-semibold tracking-tight text-slate-950 md:text-6xl'>
|
||||
DakikTabela için satıştan üretime, üretimden etkinlik yönetimine uzanan tek iş platformu.
|
||||
</h2>
|
||||
<p className='max-w-2xl text-lg leading-8 text-slate-600'>
|
||||
Bu yapı; fırsat sahipliği, takip disiplini, üretim görünürlüğü ve etkinlik koordinasyonunu
|
||||
aynı ekranda toplar. Ekipler hangi kaydın kime ait olduğunu, sıradaki işi ve kritik gecikmeleri
|
||||
anında görebilir.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap gap-4'>
|
||||
<Link
|
||||
href='/sales-hub'
|
||||
className='inline-flex items-center gap-2 rounded-full bg-cyan-500 px-6 py-3 text-sm font-semibold text-slate-950 transition hover:bg-cyan-400'
|
||||
>
|
||||
Satış merkezine git
|
||||
<svg viewBox='0 0 24 24' className='h-4 w-4 fill-current'>
|
||||
<path d={mdiArrowRight} />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
className='inline-flex items-center gap-2 rounded-full border border-slate-300 px-6 py-3 text-sm font-semibold text-slate-900 transition hover:border-cyan-400 hover:text-cyan-700'
|
||||
>
|
||||
Sisteme giriş yap
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
<div className='grid gap-3'>
|
||||
{promises.map((promise) => (
|
||||
<div key={promise} className='flex items-start gap-3 rounded-2xl bg-white/85 p-4 shadow-sm'>
|
||||
<svg viewBox='0 0 24 24' className='mt-0.5 h-5 w-5 shrink-0 fill-cyan-600'>
|
||||
<path d={mdiCheckCircleOutline} />
|
||||
</svg>
|
||||
<p className='text-sm leading-6 text-slate-700'>{promise}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
<div className='grid gap-4'>
|
||||
{platformCards.map((card) => (
|
||||
<div
|
||||
key={card.title}
|
||||
className='rounded-[28px] border border-white/80 bg-white/90 p-6 shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur'
|
||||
>
|
||||
<div className='flex items-start justify-between gap-4'>
|
||||
<div>
|
||||
<h3 className='text-xl font-semibold text-slate-950'>{card.title}</h3>
|
||||
<p className='mt-3 text-sm leading-6 text-slate-600'>{card.description}</p>
|
||||
</div>
|
||||
<div className='rounded-2xl bg-slate-900 p-3 text-white'>
|
||||
<svg viewBox='0 0 24 24' className='h-5 w-5 fill-current'>
|
||||
<path d={card.icon} />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<Link href={card.href} className='mt-5 inline-flex items-center gap-2 text-sm font-semibold text-cyan-700'>
|
||||
{card.cta}
|
||||
<svg viewBox='0 0 24 24' className='h-4 w-4 fill-current'>
|
||||
<path d={mdiArrowRight} />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
IndexPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>
|
||||
}
|
||||
|
||||
@ -154,7 +154,7 @@ export default function Login() {
|
||||
backgroundRepeat: 'no-repeat',
|
||||
} : {}}>
|
||||
<Head>
|
||||
<title>{getPageTitle('Login')}</title>
|
||||
<title>{getPageTitle('Giriş')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
@ -170,25 +170,25 @@ export default function Login() {
|
||||
<div className='flex flex-row text-gray-500 justify-between'>
|
||||
<div>
|
||||
|
||||
<p className='mb-2'>Use{' '}
|
||||
<p className='mb-2'>Kullan{' '}
|
||||
<code className={`cursor-pointer ${textColor} `}
|
||||
data-password="9c0c2fc3"
|
||||
onClick={(e) => setLogin(e.target)}>super_admin@flatlogic.com</code>{' / '}
|
||||
<code className={`${textColor}`}>9c0c2fc3</code>{' / '}
|
||||
to login as Super Admin</p>
|
||||
ile Süper Admin olarak giriş yapın</p>
|
||||
|
||||
<p className='mb-2'>Use{' '}
|
||||
<p className='mb-2'>Kullan{' '}
|
||||
<code className={`cursor-pointer ${textColor} `}
|
||||
data-password="9c0c2fc3"
|
||||
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
|
||||
<code className={`${textColor}`}>9c0c2fc3</code>{' / '}
|
||||
to login as Admin</p>
|
||||
ile Admin olarak giriş yapın</p>
|
||||
<p>Use <code
|
||||
className={`cursor-pointer ${textColor} `}
|
||||
data-password="0d5e2b23b648"
|
||||
onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '}
|
||||
<code className={`${textColor}`}>0d5e2b23b648</code>{' / '}
|
||||
to login as User</p>
|
||||
ile Kullanıcı olarak giriş yapın</p>
|
||||
</div>
|
||||
<div>
|
||||
<BaseIcon
|
||||
@ -210,15 +210,15 @@ export default function Login() {
|
||||
>
|
||||
<Form>
|
||||
<FormField
|
||||
label='Login'
|
||||
help='Please enter your login'>
|
||||
label='Giriş'
|
||||
help='Giriş e-posta adresinizi yazın'>
|
||||
<Field name='email' />
|
||||
</FormField>
|
||||
|
||||
<div className='relative'>
|
||||
<FormField
|
||||
label='Password'
|
||||
help='Please enter your password'>
|
||||
label='Şifre'
|
||||
help='Şifrenizi yazın'>
|
||||
<Field name='password' type={showPassword ? 'text' : 'password'} />
|
||||
</FormField>
|
||||
<div
|
||||
@ -234,12 +234,12 @@ export default function Login() {
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-between'}>
|
||||
<FormCheckRadio type='checkbox' label='Remember'>
|
||||
<FormCheckRadio type='checkbox' label='Beni hatırla'>
|
||||
<Field type='checkbox' name='remember' />
|
||||
</FormCheckRadio>
|
||||
|
||||
<Link className={`${textColor} text-blue-600`} href={'/forgot'}>
|
||||
Forgot password?
|
||||
Şifremi unuttum
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@ -249,16 +249,16 @@ export default function Login() {
|
||||
<BaseButton
|
||||
className={'w-full'}
|
||||
type='submit'
|
||||
label={isFetching ? 'Loading...' : 'Login'}
|
||||
label={isFetching ? 'Yükleniyor...' : 'Giriş yap'}
|
||||
color='info'
|
||||
disabled={isFetching}
|
||||
/>
|
||||
</BaseButtons>
|
||||
<br />
|
||||
<p className={'text-center'}>
|
||||
Don’t have an account yet?{' '}
|
||||
Henüz hesabınız yok mu?{' '}
|
||||
<Link className={`${textColor}`} href={'/register'}>
|
||||
New Account
|
||||
Yeni hesap oluştur
|
||||
</Link>
|
||||
</p>
|
||||
</Form>
|
||||
|
||||
@ -65,18 +65,18 @@ const EditUsers = () => {
|
||||
await dispatch(update({ id: currentUser.id, data }));
|
||||
await dispatch(findMe());
|
||||
await router.push('/users/users-list');
|
||||
notify('success', 'Profile was updated!');
|
||||
notify('success', 'Profil güncellendi.');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Edit profile')}</title>
|
||||
<title>{getPageTitle('Profilimi Düzenle')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton
|
||||
icon={mdiChartTimelineVariant}
|
||||
title='Edit profile'
|
||||
title='Profilimi Düzenle'
|
||||
main
|
||||
>
|
||||
{''}
|
||||
@ -84,7 +84,7 @@ const EditUsers = () => {
|
||||
<CardBox>
|
||||
{currentUser?.avatar[0]?.publicUrl && <div className={'grid grid-cols-6 gap-4 mb-4'}>
|
||||
<div className="col-span-1 w-80 h-80 overflow-hidden border-2 rounded-full inline-flex items-center justify-center mb-8">
|
||||
<img className="w-80 h-80 max-w-full max-h-full object-cover object-center" src={`${currentUser?.avatar[0]?.publicUrl}`} alt="Avatar" />
|
||||
<img className="w-80 h-80 max-w-full max-h-full object-cover object-center" src={`${currentUser?.avatar[0]?.publicUrl}`} alt="Profil görseli" />
|
||||
</div>
|
||||
</div>}
|
||||
<Formik
|
||||
@ -95,7 +95,7 @@ const EditUsers = () => {
|
||||
<Form>
|
||||
<FormField>
|
||||
<Field
|
||||
label='Avatar'
|
||||
label='Profil görseli'
|
||||
color='info'
|
||||
icon={mdiUpload}
|
||||
path={'users/avatar'}
|
||||
@ -108,23 +108,23 @@ const EditUsers = () => {
|
||||
component={FormImagePicker}
|
||||
></Field>
|
||||
</FormField>
|
||||
<FormField label='First Name'>
|
||||
<Field name='firstName' placeholder='First Name' />
|
||||
<FormField label='Ad'>
|
||||
<Field name='firstName' placeholder='Ad' />
|
||||
</FormField>
|
||||
|
||||
<FormField label='Last Name'>
|
||||
<Field name='lastName' placeholder='Last Name' />
|
||||
<FormField label='Soyad'>
|
||||
<Field name='lastName' placeholder='Soyad' />
|
||||
</FormField>
|
||||
|
||||
<FormField label='Phone Number'>
|
||||
<Field name='phoneNumber' placeholder='Phone Number' />
|
||||
<FormField label='Telefon'>
|
||||
<Field name='phoneNumber' placeholder='Telefon numarası' />
|
||||
</FormField>
|
||||
|
||||
<FormField label='E-Mail'>
|
||||
<Field name='email' placeholder='E-Mail' disabled />
|
||||
</FormField>
|
||||
|
||||
<FormField label='App Role' labelFor='app_role'>
|
||||
<FormField label='Uygulama rolü' labelFor='app_role'>
|
||||
<Field
|
||||
name='app_role'
|
||||
id='app_role'
|
||||
@ -135,7 +135,7 @@ const EditUsers = () => {
|
||||
></Field>
|
||||
</FormField>
|
||||
|
||||
<FormField label='Disabled' labelFor='disabled'>
|
||||
<FormField label='Pasif' labelFor='disabled'>
|
||||
<Field
|
||||
name='disabled'
|
||||
id='disabled'
|
||||
@ -144,24 +144,24 @@ const EditUsers = () => {
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Password"
|
||||
label="Şifre"
|
||||
>
|
||||
<Field
|
||||
name="password"
|
||||
placeholder="password"
|
||||
placeholder="Yeni şifre"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<BaseDivider />
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton type='submit' color='info' label='Submit' />
|
||||
<BaseButton type='reset' color='info' outline label='Reset' />
|
||||
<BaseButton type='submit' color='info' label='Kaydet' />
|
||||
<BaseButton type='reset' color='info' outline label='Formu sıfırla' />
|
||||
<BaseButton
|
||||
type='reset'
|
||||
color='danger'
|
||||
outline
|
||||
label='Cancel'
|
||||
label='İptal'
|
||||
onClick={() => router.push('/users/users-list')}
|
||||
/>
|
||||
</BaseButtons>
|
||||
|
||||
@ -69,7 +69,7 @@ export default function Register() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Login')}</title>
|
||||
<title>{getPageTitle('Kayıt Ol')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
@ -96,13 +96,13 @@ export default function Register() {
|
||||
placeholder="Select organization..."
|
||||
/>
|
||||
|
||||
<FormField label='Email' help='Please enter your email'>
|
||||
<FormField label='E-posta' help='E-posta adresinizi yazın'>
|
||||
<Field type='email' name='email' />
|
||||
</FormField>
|
||||
<FormField label='Password' help='Please enter your password'>
|
||||
<FormField label='Şifre' help='Şifrenizi yazın'>
|
||||
<Field type='password' name='password' />
|
||||
</FormField>
|
||||
<FormField label='Confirm Password' help='Please confirm your password'>
|
||||
<FormField label='Şifre Tekrarı' help='Şifrenizi tekrar yazın'>
|
||||
<Field type='password' name='confirm' />
|
||||
</FormField>
|
||||
|
||||
@ -111,12 +111,12 @@ export default function Register() {
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
type='submit'
|
||||
label={loading ? 'Loading...' : 'Register' }
|
||||
label={loading ? 'Yükleniyor...' : 'Kayıt ol' }
|
||||
color='info'
|
||||
/>
|
||||
<BaseButton
|
||||
href={'/login'}
|
||||
label={'Login'}
|
||||
label={'Giriş yap'}
|
||||
color='info'
|
||||
/>
|
||||
</BaseButtons>
|
||||
|
||||
744
frontend/src/pages/sales-hub.tsx
Normal file
744
frontend/src/pages/sales-hub.tsx
Normal file
@ -0,0 +1,744 @@
|
||||
import {
|
||||
mdiArrowRight,
|
||||
mdiCalendarClock,
|
||||
mdiChartTimelineVariant,
|
||||
mdiCheckCircleOutline,
|
||||
mdiClockAlertOutline,
|
||||
mdiCurrencyUsd,
|
||||
mdiOpenInNew,
|
||||
mdiTarget,
|
||||
} from '@mdi/js'
|
||||
import axios from 'axios'
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
import React, { ReactElement, useEffect, useMemo, useState } from 'react'
|
||||
import BaseButton from '../components/BaseButton'
|
||||
import CardBox from '../components/CardBox'
|
||||
import FormField from '../components/FormField'
|
||||
import LayoutAuthenticated from '../layouts/Authenticated'
|
||||
import NotificationBar from '../components/NotificationBar'
|
||||
import SectionMain from '../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
|
||||
import { getPageTitle } from '../config'
|
||||
import { useAppSelector } from '../stores/hooks'
|
||||
|
||||
type Stats = {
|
||||
myOpenLeads: number
|
||||
myActiveDeals: number
|
||||
dueToday: number
|
||||
overdue: number
|
||||
completedThisWeek: number
|
||||
}
|
||||
|
||||
type FollowUpItem = {
|
||||
id: string
|
||||
subject: string
|
||||
status: string
|
||||
activityType: string
|
||||
dueAt: string | null
|
||||
relatedName: string
|
||||
relatedType: 'lead' | 'deal' | 'contact' | 'activity'
|
||||
relatedId: string | null
|
||||
}
|
||||
|
||||
type RecentLead = {
|
||||
id: string
|
||||
title: string
|
||||
status: string
|
||||
estimatedValue: number | null
|
||||
nextFollowUpAt: string | null
|
||||
companyName: string | null
|
||||
stageName: string | null
|
||||
}
|
||||
|
||||
type StageSummary = {
|
||||
id: string
|
||||
name: string
|
||||
dealCount: number
|
||||
amount: number
|
||||
}
|
||||
|
||||
type QuickOption = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type SalesHubResponse = {
|
||||
stats: Stats
|
||||
followUps: FollowUpItem[]
|
||||
recentLeads: RecentLead[]
|
||||
stageSummary: StageSummary[]
|
||||
quickOptions: {
|
||||
leads: QuickOption[]
|
||||
deals: QuickOption[]
|
||||
contacts: QuickOption[]
|
||||
}
|
||||
}
|
||||
|
||||
type RelationType = 'lead' | 'deal' | 'contact'
|
||||
|
||||
type FollowUpForm = {
|
||||
subject: string
|
||||
activityType: string
|
||||
dueAt: string
|
||||
relationType: RelationType
|
||||
relationId: string
|
||||
details: string
|
||||
}
|
||||
|
||||
const emptyHubData: SalesHubResponse = {
|
||||
stats: {
|
||||
myOpenLeads: 0,
|
||||
myActiveDeals: 0,
|
||||
dueToday: 0,
|
||||
overdue: 0,
|
||||
completedThisWeek: 0,
|
||||
},
|
||||
followUps: [],
|
||||
recentLeads: [],
|
||||
stageSummary: [],
|
||||
quickOptions: {
|
||||
leads: [],
|
||||
deals: [],
|
||||
contacts: [],
|
||||
},
|
||||
}
|
||||
|
||||
const defaultForm: FollowUpForm = {
|
||||
subject: '',
|
||||
activityType: 'follow_up',
|
||||
dueAt: '',
|
||||
relationType: 'lead',
|
||||
relationId: '',
|
||||
details: '',
|
||||
}
|
||||
|
||||
const relationLabels: Record<RelationType, string> = {
|
||||
lead: 'potansiyel müşteri',
|
||||
deal: 'fırsat',
|
||||
contact: 'kişi',
|
||||
}
|
||||
|
||||
const relationPaths: Record<'lead' | 'deal' | 'contact', string> = {
|
||||
lead: '/leads',
|
||||
deal: '/deals',
|
||||
contact: '/contacts',
|
||||
}
|
||||
|
||||
const activityTypeLabels: Record<string, string> = {
|
||||
call: 'Arama',
|
||||
email: 'E-posta',
|
||||
meeting: 'Toplantı',
|
||||
task: 'Görev',
|
||||
note: 'Not',
|
||||
demo: 'Demo',
|
||||
follow_up: 'Takip',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
planned: 'Planlandı',
|
||||
done: 'Tamamlandı',
|
||||
canceled: 'İptal edildi',
|
||||
in_progress: 'Devam ediyor',
|
||||
}
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
key: 'myOpenLeads',
|
||||
label: 'Açık potansiyel müşteri',
|
||||
helper: 'Henüz nitelendirilmemiş veya sıradaki temas bekleyen kayıtlar.',
|
||||
icon: mdiTarget,
|
||||
},
|
||||
{
|
||||
key: 'myActiveDeals',
|
||||
label: 'Aktif fırsat',
|
||||
helper: 'Şu anda sizin sorumluluğunuzdaki fırsatlar.',
|
||||
icon: mdiCurrencyUsd,
|
||||
},
|
||||
{
|
||||
key: 'dueToday',
|
||||
label: 'Bugün aksiyon bekleyen',
|
||||
helper: 'Gün bitmeden dokunulması gereken takipler.',
|
||||
icon: mdiCalendarClock,
|
||||
},
|
||||
{
|
||||
key: 'overdue',
|
||||
label: 'Geciken',
|
||||
helper: 'Tarihi geçmiş ve momentum kaybetme riski taşıyan işler.',
|
||||
icon: mdiClockAlertOutline,
|
||||
},
|
||||
] as const
|
||||
|
||||
const quickActionLinks = [
|
||||
{
|
||||
href: '/leads/leads-new',
|
||||
label: 'Yeni potansiyel müşteri',
|
||||
description: 'Sahibi belli yeni bir fırsatı ilk günden sisteme alın.',
|
||||
},
|
||||
{
|
||||
href: '/deals/deals-new',
|
||||
label: 'Yeni fırsat aç',
|
||||
description: 'Nitelenen işleri aktif pipeline içine taşıyın.',
|
||||
},
|
||||
{
|
||||
href: '/contacts/contacts-new',
|
||||
label: 'Yeni kişi ekle',
|
||||
description: 'Her fırsatın arkasındaki karar vericileri görünür tutun.',
|
||||
},
|
||||
{
|
||||
href: '/activities/activities-list',
|
||||
label: 'Tüm aktiviteler',
|
||||
description: 'Görev, toplantı, e-posta ve takiplerin tamamını inceleyin.',
|
||||
},
|
||||
]
|
||||
|
||||
const formatCurrency = (value?: number | null) => {
|
||||
const amount = Number(value || 0)
|
||||
|
||||
return new Intl.NumberFormat('tr-TR', {
|
||||
style: 'currency',
|
||||
currency: 'TRY',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
const formatDateTime = (value?: string | null) => {
|
||||
if (!value) {
|
||||
return 'Termin yok'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('tr-TR', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
const getActivityTone = (dueAt?: string | null) => {
|
||||
if (!dueAt) {
|
||||
return 'bg-slate-100 text-slate-700 dark:bg-slate-900/70 dark:text-slate-300'
|
||||
}
|
||||
|
||||
const dueDate = new Date(dueAt).getTime()
|
||||
const today = new Date()
|
||||
const startOfTomorrow = new Date(today)
|
||||
startOfTomorrow.setHours(24, 0, 0, 0)
|
||||
|
||||
if (dueDate < Date.now()) {
|
||||
return 'bg-rose-100 text-rose-700 dark:bg-rose-950/50 dark:text-rose-300'
|
||||
}
|
||||
|
||||
if (dueDate < startOfTomorrow.getTime()) {
|
||||
return 'bg-amber-100 text-amber-700 dark:bg-amber-950/50 dark:text-amber-300'
|
||||
}
|
||||
|
||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300'
|
||||
}
|
||||
|
||||
const SalesHub = () => {
|
||||
const { currentUser } = useAppSelector((state) => state.auth)
|
||||
const [hubData, setHubData] = useState<SalesHubResponse>(emptyHubData)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingError, setLoadingError] = useState('')
|
||||
const [form, setForm] = useState<FollowUpForm>(defaultForm)
|
||||
const [formError, setFormError] = useState('')
|
||||
const [successState, setSuccessState] = useState<{ id: string; subject: string } | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const loadHub = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setLoadingError('')
|
||||
const { data } = await axios.get<SalesHubResponse>('/sales-hub')
|
||||
setHubData(data)
|
||||
} catch (error: any) {
|
||||
setLoadingError(
|
||||
error?.response?.data?.message || 'Satış merkezi yüklenemedi. Lütfen tekrar deneyin.',
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser?.id) {
|
||||
loadHub()
|
||||
}
|
||||
}, [currentUser?.id])
|
||||
|
||||
const relationOptions = useMemo(() => {
|
||||
return hubData.quickOptions[`${form.relationType}s` as keyof SalesHubResponse['quickOptions']] || []
|
||||
}, [form.relationType, hubData.quickOptions])
|
||||
|
||||
useEffect(() => {
|
||||
if (!relationOptions.length) {
|
||||
setForm((currentForm) => ({
|
||||
...currentForm,
|
||||
relationId: '',
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (!relationOptions.find((option) => option.id === form.relationId)) {
|
||||
setForm((currentForm) => ({
|
||||
...currentForm,
|
||||
relationId: relationOptions[0]?.id || '',
|
||||
}))
|
||||
}
|
||||
}, [relationOptions, form.relationId])
|
||||
|
||||
const handleFormChange =
|
||||
(field: keyof FollowUpForm) =>
|
||||
(event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const value = event.target.value
|
||||
setFormError('')
|
||||
setSuccessState(null)
|
||||
setForm((currentForm) => ({
|
||||
...currentForm,
|
||||
[field]: value,
|
||||
...(field === 'relationType' ? { relationId: '' } : {}),
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
setFormError('')
|
||||
setSuccessState(null)
|
||||
|
||||
if (!form.subject.trim()) {
|
||||
setFormError('Takibin ne için açıldığını açıklayan kısa bir konu yazın.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.dueAt) {
|
||||
setFormError('Takip için bir tarih ve saat seçin.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.relationId) {
|
||||
setFormError(`Takibin bağlanacağı bir ${relationLabels[form.relationType]} seçin.`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
const { data } = await axios.post('/sales-hub/follow-ups', {
|
||||
data: {
|
||||
subject: form.subject.trim(),
|
||||
activity_type: form.activityType,
|
||||
due_at: new Date(form.dueAt).toISOString(),
|
||||
relationType: form.relationType,
|
||||
relationId: form.relationId,
|
||||
details: form.details.trim(),
|
||||
},
|
||||
})
|
||||
|
||||
setSuccessState(data)
|
||||
setForm((currentForm) => ({
|
||||
...defaultForm,
|
||||
relationType: currentForm.relationType,
|
||||
}))
|
||||
await loadHub()
|
||||
} catch (error: any) {
|
||||
setFormError(
|
||||
error?.response?.data?.message || 'Takip planlanamadı. Lütfen formu kontrol edip tekrar deneyin.',
|
||||
)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Satış Merkezi')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Satış Merkezi' main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<div className='grid gap-6'>
|
||||
<CardBox className='overflow-hidden border-slate-900 bg-gradient-to-br from-[#0b1020] via-[#13213d] to-[#1d4ed8] text-white shadow-2xl shadow-slate-950/20'>
|
||||
<div className='grid gap-8 lg:grid-cols-[1.4fr_0.9fr]'>
|
||||
<div className='space-y-5'>
|
||||
<div className='inline-flex items-center rounded-full border border-white/20 bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-cyan-100'>
|
||||
DakikTabela · CRM operasyon odağı
|
||||
</div>
|
||||
<div className='space-y-3'>
|
||||
<h2 className='text-3xl font-semibold tracking-tight md:text-4xl'>
|
||||
Pipeline sorumluluğunu görün, sıradaki işi kaçırmayın, takip disiplinini koruyun.
|
||||
</h2>
|
||||
<p className='max-w-2xl text-sm leading-6 text-slate-200 md:text-base'>
|
||||
Bu ekran günlük satış ritmini tek yerde toplar: size ait fırsatları inceleyin, geciken işleri
|
||||
görün, yeni takip planlayın ve doğrudan ilgili kayda geçin.
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
|
||||
{quickActionLinks.map((action) => (
|
||||
<Link
|
||||
key={action.href}
|
||||
href={action.href}
|
||||
className='group rounded-2xl border border-white/10 bg-white/10 p-4 transition hover:border-cyan-300/50 hover:bg-white/15'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<span className='font-semibold text-white'>{action.label}</span>
|
||||
<span className='rounded-full bg-white/10 p-2 transition group-hover:bg-cyan-400/20'>
|
||||
<svg viewBox='0 0 24 24' className='h-4 w-4 fill-current'>
|
||||
<path d={mdiArrowRight} />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<p className='mt-3 text-sm leading-6 text-slate-200'>{action.description}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-3 rounded-3xl border border-white/10 bg-slate-950/25 p-5 backdrop-blur-sm'>
|
||||
<p className='text-sm font-semibold text-cyan-100'>Bugünün ritmi</p>
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<p className='text-4xl font-semibold'>{hubData.stats.completedThisWeek}</p>
|
||||
<p className='mt-1 text-sm text-slate-200'>Bu hafta tamamlanan takip</p>
|
||||
</div>
|
||||
<div className='grid gap-3 sm:grid-cols-2 lg:grid-cols-1'>
|
||||
<div className='rounded-2xl border border-white/10 bg-white/10 p-4'>
|
||||
<p className='text-xs uppercase tracking-[0.2em] text-slate-300'>Önerilen aksiyon</p>
|
||||
<p className='mt-2 font-medium'>Her lead ve fırsat için tarihli bir sonraki adım belirleyin.</p>
|
||||
</div>
|
||||
<div className='rounded-2xl border border-white/10 bg-white/10 p-4'>
|
||||
<p className='text-xs uppercase tracking-[0.2em] text-slate-300'>İyi uygulama</p>
|
||||
<p className='mt-2 font-medium'>Her takipte tek sahip, net konu ve kesin termin olsun.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
{loadingError && (
|
||||
<NotificationBar color='danger'>
|
||||
<span>{loadingError}</span>
|
||||
</NotificationBar>
|
||||
)}
|
||||
|
||||
{successState && (
|
||||
<NotificationBar
|
||||
color='success'
|
||||
button={
|
||||
<BaseButton
|
||||
color='success'
|
||||
href={`/activities/${successState.id}`}
|
||||
label='Aktiviteyi aç'
|
||||
icon={mdiOpenInNew}
|
||||
small
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span>
|
||||
Takip planlandı: <strong>{successState.subject}</strong>
|
||||
</span>
|
||||
</NotificationBar>
|
||||
)}
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-[1.45fr_0.95fr]'>
|
||||
<div className='space-y-6'>
|
||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
{statCards.map((card) => (
|
||||
<CardBox key={card.key} className='border-slate-200/70 bg-white/90 shadow-sm dark:border-dark-700'>
|
||||
<div className='flex items-start justify-between gap-4'>
|
||||
<div>
|
||||
<p className='text-sm font-semibold text-slate-500 dark:text-slate-300'>{card.label}</p>
|
||||
<p className='mt-3 text-4xl font-semibold text-slate-900 dark:text-white'>
|
||||
{hubData.stats[card.key]}
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-2xl bg-slate-100 p-3 text-slate-700 dark:bg-slate-800 dark:text-slate-100'>
|
||||
<svg viewBox='0 0 24 24' className='h-5 w-5 fill-current'>
|
||||
<path d={card.icon} />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className='mt-4 text-sm leading-6 text-slate-500 dark:text-slate-300'>{card.helper}</p>
|
||||
</CardBox>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<CardBox className='border-slate-200/70 bg-white/95 shadow-sm dark:border-dark-700'>
|
||||
<div className='flex flex-col gap-3 border-b border-slate-200 pb-4 dark:border-dark-700 md:flex-row md:items-end md:justify-between'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-cyan-600 dark:text-cyan-300'>
|
||||
Öncelikli işler
|
||||
</p>
|
||||
<h3 className='mt-1 text-2xl font-semibold text-slate-900 dark:text-white'>Takip kuyruğum</h3>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-300'>
|
||||
Size atanan sıradaki aktiviteler burada listelenir. Aktiviteyi veya bağlı kaydı tek tıkla açabilirsiniz.
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton color='info' href='/activities/activities-list' label='Tüm aktiviteler' small />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className='grid gap-3 py-6'>
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className='h-24 animate-pulse rounded-2xl bg-slate-100 dark:bg-slate-800' />
|
||||
))}
|
||||
</div>
|
||||
) : hubData.followUps.length ? (
|
||||
<div className='grid gap-3 pt-6'>
|
||||
{hubData.followUps.map((item) => {
|
||||
const relatedPath =
|
||||
item.relatedType !== 'activity' && item.relatedId
|
||||
? `${relationPaths[item.relatedType]}/${item.relatedId}`
|
||||
: null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className='grid gap-3 rounded-2xl border border-slate-200/80 bg-slate-50/70 p-4 dark:border-dark-700 dark:bg-dark-900/50 lg:grid-cols-[1.2fr_0.9fr_auto] lg:items-center'
|
||||
>
|
||||
<div>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${getActivityTone(item.dueAt)}`}>
|
||||
{formatDateTime(item.dueAt)}
|
||||
</span>
|
||||
<span className='rounded-full bg-slate-900 px-2.5 py-1 text-xs font-semibold uppercase tracking-wide text-white dark:bg-slate-100 dark:text-slate-900'>
|
||||
{activityTypeLabels[item.activityType] || item.activityType}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className='mt-3 text-lg font-semibold text-slate-900 dark:text-white'>{item.subject}</h4>
|
||||
<p className='mt-1 text-sm text-slate-500 dark:text-slate-300'>Bağlı kayıt: {item.relatedName}</p>
|
||||
</div>
|
||||
<div className='text-sm text-slate-500 dark:text-slate-300'>
|
||||
<p className='font-medium text-slate-900 dark:text-white'>
|
||||
Durum: {statusLabels[item.status] || item.status.replace('_', ' ')}
|
||||
</p>
|
||||
<p className='mt-1'>Termin kaymadan önce sıradaki teması tamamlayın.</p>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2 lg:justify-end'>
|
||||
<BaseButton color='info' href={`/activities/${item.id}`} label='Aktivite detayı' small />
|
||||
{relatedPath && (
|
||||
<BaseButton color='white' outline href={relatedPath} label='Kaydı aç' small />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className='rounded-2xl border border-dashed border-slate-300 bg-slate-50/70 px-6 py-8 text-center dark:border-dark-600 dark:bg-dark-900/50'>
|
||||
<p className='text-lg font-semibold text-slate-900 dark:text-white'>Size atanmış bekleyen takip yok.</p>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-300'>
|
||||
Yeni bir takip planlayabilir veya aktivite listesinden tamamlanan işleri gözden geçirebilirsiniz.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
|
||||
<CardBox className='border-slate-200/70 bg-white/95 shadow-sm dark:border-dark-700'>
|
||||
<div className='flex flex-col gap-3 border-b border-slate-200 pb-4 dark:border-dark-700 md:flex-row md:items-end md:justify-between'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-cyan-600 dark:text-cyan-300'>
|
||||
Yeni akış
|
||||
</p>
|
||||
<h3 className='mt-1 text-2xl font-semibold text-slate-900 dark:text-white'>Son potansiyel müşteriler</h3>
|
||||
</div>
|
||||
<BaseButton color='info' href='/leads/leads-list' label='Lead listesini aç' small />
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className='grid gap-3 py-6'>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className='h-20 animate-pulse rounded-2xl bg-slate-100 dark:bg-slate-800' />
|
||||
))}
|
||||
</div>
|
||||
) : hubData.recentLeads.length ? (
|
||||
<div className='grid gap-3 pt-6'>
|
||||
{hubData.recentLeads.map((lead) => (
|
||||
<Link
|
||||
key={lead.id}
|
||||
href={`/leads/${lead.id}`}
|
||||
className='grid gap-3 rounded-2xl border border-slate-200/80 bg-slate-50/70 p-4 transition hover:border-cyan-300 hover:bg-white dark:border-dark-700 dark:bg-dark-900/50 dark:hover:border-cyan-500 lg:grid-cols-[1.1fr_0.9fr_auto] lg:items-center'
|
||||
>
|
||||
<div>
|
||||
<p className='text-lg font-semibold text-slate-900 dark:text-white'>{lead.title}</p>
|
||||
<p className='mt-1 text-sm text-slate-500 dark:text-slate-300'>
|
||||
{lead.companyName || 'Şirket bağlı değil'} · {lead.stageName || 'Aşama seçilmedi'}
|
||||
</p>
|
||||
</div>
|
||||
<div className='text-sm text-slate-500 dark:text-slate-300'>
|
||||
<p className='font-medium text-slate-900 dark:text-white'>{formatCurrency(lead.estimatedValue)}</p>
|
||||
<p className='mt-1'>Sonraki temas: {formatDateTime(lead.nextFollowUpAt)}</p>
|
||||
</div>
|
||||
<div className='justify-self-start rounded-full bg-slate-900 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white dark:bg-slate-100 dark:text-slate-900'>
|
||||
{lead.status}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='rounded-2xl border border-dashed border-slate-300 bg-slate-50/70 px-6 py-8 text-center dark:border-dark-600 dark:bg-dark-900/50'>
|
||||
<p className='text-lg font-semibold text-slate-900 dark:text-white'>Henüz aktif potansiyel müşteri yok.</p>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-300'>
|
||||
İlk lead kaydınızı oluşturduğunuzda bu alanda yeni akış görünmeye başlar.
|
||||
</p>
|
||||
<div className='mt-5 flex justify-center'>
|
||||
<BaseButton color='info' href='/leads/leads-new' label='İlk lead kaydını oluştur' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
</div>
|
||||
|
||||
<div className='space-y-6'>
|
||||
<CardBox className='border-slate-200/70 bg-white/95 shadow-sm dark:border-dark-700'>
|
||||
<div className='border-b border-slate-200 pb-4 dark:border-dark-700'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-cyan-600 dark:text-cyan-300'>
|
||||
Planlı aksiyon
|
||||
</p>
|
||||
<h3 className='mt-1 text-2xl font-semibold text-slate-900 dark:text-white'>Yeni takip planla</h3>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-300'>
|
||||
Takibi bir lead, fırsat veya kişiye bağlayın; konu, tarih ve not ile ekipteki sorumluluğu netleştirin.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className='pt-6' onSubmit={handleSubmit}>
|
||||
<FormField label='Konu' help='Takibin neden açıldığını kısa ve net yazın.'>
|
||||
<input value={form.subject} onChange={handleFormChange('subject')} placeholder='Örn. teklif sonrası arama' />
|
||||
</FormField>
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<FormField label='Takip tipi' help='Bu kaydın nasıl ilerletileceğini seçin.'>
|
||||
<select value={form.activityType} onChange={handleFormChange('activityType')}>
|
||||
{Object.entries(activityTypeLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<FormField label='Termin' help='Takibin tamamlanması gereken tarih ve saat.'>
|
||||
<input type='datetime-local' value={form.dueAt} onChange={handleFormChange('dueAt')} />
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label='Bağlı kayıt tipi' help='Takibin bağlanacağı kayıt türünü seçin.'>
|
||||
<select value={form.relationType} onChange={handleFormChange('relationType')}>
|
||||
<option value='lead'>Potansiyel müşteri</option>
|
||||
<option value='deal'>Fırsat</option>
|
||||
<option value='contact'>Kişi</option>
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label='Bağlı kayıt'
|
||||
help={`Sadece size açık olan ${relationLabels[form.relationType]} kayıtları burada görünür.`}
|
||||
>
|
||||
<select value={form.relationId} onChange={handleFormChange('relationId')}>
|
||||
<option value=''>Kayıt seçin</option>
|
||||
{relationOptions.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
{!relationOptions.length && (
|
||||
<div className='mb-6 rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-4 py-4 text-sm text-slate-600 dark:border-dark-600 dark:bg-dark-900/50 dark:text-slate-300'>
|
||||
Önce bir {relationLabels[form.relationType]} oluşturun veya size atanmış kayıtları güncelleyin; ardından bu alanda seçim yapabilirsiniz.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField label='Notlar' help='Bir sonraki temas için gerekli bağlam, risk veya beklentiyi yazın.'>
|
||||
<textarea
|
||||
value={form.details}
|
||||
onChange={handleFormChange('details')}
|
||||
placeholder='Örn. fiyat itirazı var, revize teklif gönderildikten sonra aranacak.'
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{formError && (
|
||||
<NotificationBar color='danger'>
|
||||
<span>{formError}</span>
|
||||
</NotificationBar>
|
||||
)}
|
||||
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<BaseButton
|
||||
type='submit'
|
||||
color='info'
|
||||
label={isSubmitting ? 'Planlanıyor…' : 'Takibi planla'}
|
||||
icon={mdiCheckCircleOutline}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<BaseButton color='white' outline label='Aktivite listesine git' href='/activities/activities-list' />
|
||||
</div>
|
||||
</form>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className='border-slate-200/70 bg-white/95 shadow-sm dark:border-dark-700'>
|
||||
<div className='border-b border-slate-200 pb-4 dark:border-dark-700'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-cyan-600 dark:text-cyan-300'>Özet görünüm</p>
|
||||
<h3 className='mt-1 text-2xl font-semibold text-slate-900 dark:text-white'>Aşamaya göre fırsatlar</h3>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-300'>
|
||||
Aktif pipeline'ı aşama ve toplam değer bazında görün; satış ritmini günlük ekranınızdan takip edin.
|
||||
</p>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className='grid gap-3 py-6'>
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className='h-16 animate-pulse rounded-2xl bg-slate-100 dark:bg-slate-800' />
|
||||
))}
|
||||
</div>
|
||||
) : hubData.stageSummary.length ? (
|
||||
<div className='grid gap-3 pt-6'>
|
||||
{hubData.stageSummary.map((stage) => (
|
||||
<div
|
||||
key={stage.id}
|
||||
className='rounded-2xl border border-slate-200/80 bg-slate-50/70 p-4 dark:border-dark-700 dark:bg-dark-900/50'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<div>
|
||||
<p className='font-semibold text-slate-900 dark:text-white'>{stage.name}</p>
|
||||
<p className='mt-1 text-sm text-slate-500 dark:text-slate-300'>
|
||||
{stage.dealCount} aktif {stage.dealCount === 1 ? 'fırsat' : 'fırsat'}
|
||||
</p>
|
||||
</div>
|
||||
<div className='text-right'>
|
||||
<p className='font-semibold text-slate-900 dark:text-white'>{formatCurrency(stage.amount)}</p>
|
||||
<p className='mt-1 text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400'>
|
||||
toplam pipeline
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='rounded-2xl border border-dashed border-slate-300 bg-slate-50/70 px-6 py-8 text-center dark:border-dark-600 dark:bg-dark-900/50'>
|
||||
<p className='text-lg font-semibold text-slate-900 dark:text-white'>Henüz açık fırsat yok.</p>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-300'>
|
||||
Fırsatlar aktif hale geldiğinde bu alan aşama dağılımını ve toplam değeri gösterecek.
|
||||
</p>
|
||||
<div className='mt-5 flex justify-center'>
|
||||
<BaseButton color='info' href='/deals/deals-new' label='İlk fırsatı oluştur' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
SalesHub.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
|
||||
}
|
||||
|
||||
export default SalesHub
|
||||
@ -1,9 +1,7 @@
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import Head from 'next/head';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import { useAppDispatch } from '../stores/hooks';
|
||||
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
|
||||
import { useRouter } from 'next/router';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
@ -57,12 +55,12 @@ const SearchView = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Search Result</title>
|
||||
<title>Arama Sonuçları</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton
|
||||
icon={mdiChartTimelineVariant}
|
||||
title={'Search Result'}
|
||||
title={'Arama Sonuçları'}
|
||||
main
|
||||
>
|
||||
{''}
|
||||
@ -75,7 +73,7 @@ const SearchView = () => {
|
||||
<BaseDivider />
|
||||
<BaseButton
|
||||
color='info'
|
||||
label='Back'
|
||||
label='Geri dön'
|
||||
onClick={() => router.push('/dashboard')}
|
||||
/>
|
||||
</CardBox>
|
||||
|
||||
@ -44,11 +44,11 @@ export default function Verify() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Verify Email')}</title>
|
||||
<title>{getPageTitle('E-posta Doğrulama')}</title>
|
||||
</Head>
|
||||
<SectionFullScreen bg='violet'>
|
||||
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
|
||||
<p>{loading ? 'Loading...' : ''}</p>
|
||||
<p>{loading ? 'Doğrulanıyor...' : ''}</p>
|
||||
</CardBox>
|
||||
</SectionFullScreen>
|
||||
|
||||
|
||||
@ -41,6 +41,9 @@ module.exports = {
|
||||
'fade-out': 'fade-out 250ms ease-in-out',
|
||||
'fade-in': 'fade-in 250ms ease-in-out'
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Poppins', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
dark: {
|
||||
900: '#131618',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user