feat: desktop image thumbnails, gallery lightbox portal, client-side compression, clickable table rows, fix header gap
This commit is contained in:
@@ -3,6 +3,10 @@ import type { NextConfig } from "next";
|
|||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
turbopack: {},
|
turbopack: {},
|
||||||
|
|
||||||
|
experimental: {
|
||||||
|
optimizePackageImports: ["@phosphor-icons/react"],
|
||||||
|
},
|
||||||
|
|
||||||
typescript: { ignoreBuildErrors: true },
|
typescript: { ignoreBuildErrors: true },
|
||||||
|
|
||||||
images: {
|
images: {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@polar-sh/sdk": "^0.47.1",
|
"@polar-sh/sdk": "^0.47.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
@@ -40,16 +41,19 @@
|
|||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"appwrite": "^24.2.0",
|
"appwrite": "^24.2.0",
|
||||||
|
"browser-image-compression": "^2.0.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"driver.js": "^1.4.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"maplibre-gl": "^5.24.0",
|
"maplibre-gl": "^5.24.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-appwrite": "^23.1.0",
|
"node-appwrite": "^23.1.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
|||||||
Generated
+521
@@ -23,6 +23,9 @@ importers:
|
|||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.2.2
|
specifier: ^5.2.2
|
||||||
version: 5.2.2(react-hook-form@7.69.0(react@19.2.3))
|
version: 5.2.2(react-hook-form@7.69.0(react@19.2.3))
|
||||||
|
'@phosphor-icons/react':
|
||||||
|
specifier: ^2.1.10
|
||||||
|
version: 2.1.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
'@polar-sh/sdk':
|
'@polar-sh/sdk':
|
||||||
specifier: ^0.47.1
|
specifier: ^0.47.1
|
||||||
version: 0.47.1
|
version: 0.47.1
|
||||||
@@ -98,6 +101,9 @@ importers:
|
|||||||
appwrite:
|
appwrite:
|
||||||
specifier: ^24.2.0
|
specifier: ^24.2.0
|
||||||
version: 24.2.0
|
version: 24.2.0
|
||||||
|
browser-image-compression:
|
||||||
|
specifier: ^2.0.2
|
||||||
|
version: 2.0.2
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -110,6 +116,9 @@ importers:
|
|||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
|
driver.js:
|
||||||
|
specifier: ^1.4.0
|
||||||
|
version: 1.4.0
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.562.0
|
specifier: ^0.562.0
|
||||||
version: 0.562.0(react@19.2.3)
|
version: 0.562.0(react@19.2.3)
|
||||||
@@ -128,6 +137,9 @@ importers:
|
|||||||
postcss:
|
postcss:
|
||||||
specifier: ^8.5.6
|
specifier: ^8.5.6
|
||||||
version: 8.5.6
|
version: 8.5.6
|
||||||
|
radix-ui:
|
||||||
|
specifier: ^1.4.3
|
||||||
|
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
react:
|
react:
|
||||||
specifier: 19.2.3
|
specifier: 19.2.3
|
||||||
version: 19.2.3
|
version: 19.2.3
|
||||||
@@ -662,6 +674,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||||
engines: {node: '>=12.4.0'}
|
engines: {node: '>=12.4.0'}
|
||||||
|
|
||||||
|
'@phosphor-icons/react@2.1.10':
|
||||||
|
resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>= 16.8'
|
||||||
|
react-dom: '>= 16.8'
|
||||||
|
|
||||||
'@polar-sh/sdk@0.47.1':
|
'@polar-sh/sdk@0.47.1':
|
||||||
resolution: {integrity: sha512-fkz7wPLbqfuDmY9LxuXpE2uP2TAV6J0q/YN5hJ4UBxpjbkB0hKM6c4R35N89t83dfzMlG6EOlqOn+Rd1T6XrJQ==}
|
resolution: {integrity: sha512-fkz7wPLbqfuDmY9LxuXpE2uP2TAV6J0q/YN5hJ4UBxpjbkB0hKM6c4R35N89t83dfzMlG6EOlqOn+Rd1T6XrJQ==}
|
||||||
|
|
||||||
@@ -671,6 +690,19 @@ packages:
|
|||||||
'@radix-ui/primitive@1.1.3':
|
'@radix-ui/primitive@1.1.3':
|
||||||
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
||||||
|
|
||||||
|
'@radix-ui/react-accessible-icon@1.1.7':
|
||||||
|
resolution: {integrity: sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-accordion@1.2.12':
|
'@radix-ui/react-accordion@1.2.12':
|
||||||
resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==}
|
resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -684,6 +716,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-alert-dialog@1.1.15':
|
||||||
|
resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-arrow@1.1.7':
|
'@radix-ui/react-arrow@1.1.7':
|
||||||
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
|
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -697,6 +742,32 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-aspect-ratio@1.1.7':
|
||||||
|
resolution: {integrity: sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-avatar@1.1.10':
|
||||||
|
resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-avatar@1.1.11':
|
'@radix-ui/react-avatar@1.1.11':
|
||||||
resolution: {integrity: sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==}
|
resolution: {integrity: sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -758,6 +829,19 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-context-menu@2.2.16':
|
||||||
|
resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-context@1.1.2':
|
'@radix-ui/react-context@1.1.2':
|
||||||
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
|
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -846,6 +930,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-form@0.1.8':
|
||||||
|
resolution: {integrity: sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-hover-card@1.1.15':
|
'@radix-ui/react-hover-card@1.1.15':
|
||||||
resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==}
|
resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -868,6 +965,19 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-label@2.1.7':
|
||||||
|
resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-label@2.1.8':
|
'@radix-ui/react-label@2.1.8':
|
||||||
resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==}
|
resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -894,6 +1004,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-menubar@1.1.16':
|
||||||
|
resolution: {integrity: sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-navigation-menu@1.2.14':
|
'@radix-ui/react-navigation-menu@1.2.14':
|
||||||
resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==}
|
resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -907,6 +1030,32 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-one-time-password-field@0.1.8':
|
||||||
|
resolution: {integrity: sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-password-toggle-field@0.1.3':
|
||||||
|
resolution: {integrity: sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-popover@1.1.15':
|
'@radix-ui/react-popover@1.1.15':
|
||||||
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
|
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -985,6 +1134,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-progress@1.1.7':
|
||||||
|
resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-progress@1.1.8':
|
'@radix-ui/react-progress@1.1.8':
|
||||||
resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==}
|
resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1050,6 +1212,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-separator@1.1.7':
|
||||||
|
resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-separator@1.1.8':
|
'@radix-ui/react-separator@1.1.8':
|
||||||
resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==}
|
resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1063,6 +1238,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-slider@1.3.6':
|
||||||
|
resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-slot@1.2.3':
|
'@radix-ui/react-slot@1.2.3':
|
||||||
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1107,6 +1295,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-toast@1.2.15':
|
||||||
|
resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-toggle-group@1.1.11':
|
'@radix-ui/react-toggle-group@1.1.11':
|
||||||
resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==}
|
resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1133,6 +1334,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-toolbar@1.1.11':
|
||||||
|
resolution: {integrity: sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-tooltip@1.2.8':
|
'@radix-ui/react-tooltip@1.2.8':
|
||||||
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
|
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1696,6 +1910,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
browser-image-compression@2.0.2:
|
||||||
|
resolution: {integrity: sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==}
|
||||||
|
|
||||||
browserslist@4.28.1:
|
browserslist@4.28.1:
|
||||||
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
|
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
|
||||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||||
@@ -1867,6 +2084,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
driver.js@1.4.0:
|
||||||
|
resolution: {integrity: sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2691,6 +2911,19 @@ packages:
|
|||||||
quickselect@3.0.0:
|
quickselect@3.0.0:
|
||||||
resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==}
|
resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==}
|
||||||
|
|
||||||
|
radix-ui@1.4.3:
|
||||||
|
resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
react-day-picker@9.13.0:
|
react-day-picker@9.13.0:
|
||||||
resolution: {integrity: sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==}
|
resolution: {integrity: sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -3076,6 +3309,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
uzip@0.20201231.0:
|
||||||
|
resolution: {integrity: sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==}
|
||||||
|
|
||||||
vaul@1.1.2:
|
vaul@1.1.2:
|
||||||
resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==}
|
resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3589,6 +3825,11 @@ snapshots:
|
|||||||
|
|
||||||
'@nolyfill/is-core-module@1.0.39': {}
|
'@nolyfill/is-core-module@1.0.39': {}
|
||||||
|
|
||||||
|
'@phosphor-icons/react@2.1.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
|
||||||
'@polar-sh/sdk@0.47.1':
|
'@polar-sh/sdk@0.47.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
standardwebhooks: 1.0.0
|
standardwebhooks: 1.0.0
|
||||||
@@ -3598,6 +3839,15 @@ snapshots:
|
|||||||
|
|
||||||
'@radix-ui/primitive@1.1.3': {}
|
'@radix-ui/primitive@1.1.3': {}
|
||||||
|
|
||||||
|
'@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
'@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
'@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@radix-ui/primitive': 1.1.3
|
||||||
@@ -3615,6 +3865,20 @@ snapshots:
|
|||||||
'@types/react': 19.2.7
|
'@types/react': 19.2.7
|
||||||
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
|
'@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
@@ -3624,6 +3888,28 @@ snapshots:
|
|||||||
'@types/react': 19.2.7
|
'@types/react': 19.2.7
|
||||||
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
|
'@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
|
'@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
'@radix-ui/react-avatar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
'@radix-ui/react-avatar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-context': 1.1.3(@types/react@19.2.7)(react@19.2.3)
|
'@radix-ui/react-context': 1.1.3(@types/react@19.2.7)(react@19.2.3)
|
||||||
@@ -3687,6 +3973,20 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.7
|
'@types/react': 19.2.7
|
||||||
|
|
||||||
|
'@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
'@radix-ui/react-context@1.1.2(@types/react@19.2.7)(react@19.2.3)':
|
'@radix-ui/react-context@1.1.2(@types/react@19.2.7)(react@19.2.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.3
|
react: 19.2.3
|
||||||
@@ -3772,6 +4072,20 @@ snapshots:
|
|||||||
'@types/react': 19.2.7
|
'@types/react': 19.2.7
|
||||||
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
|
'@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
'@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
'@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@radix-ui/primitive': 1.1.3
|
||||||
@@ -3796,6 +4110,15 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.7
|
'@types/react': 19.2.7
|
||||||
|
|
||||||
|
'@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
'@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
'@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
@@ -3831,6 +4154,24 @@ snapshots:
|
|||||||
'@types/react': 19.2.7
|
'@types/react': 19.2.7
|
||||||
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
|
'@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
'@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
'@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@radix-ui/primitive': 1.1.3
|
||||||
@@ -3853,6 +4194,42 @@ snapshots:
|
|||||||
'@types/react': 19.2.7
|
'@types/react': 19.2.7
|
||||||
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
|
'@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/number': 1.1.1
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
|
'@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
'@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
'@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@radix-ui/primitive': 1.1.3
|
||||||
@@ -3932,6 +4309,16 @@ snapshots:
|
|||||||
'@types/react': 19.2.7
|
'@types/react': 19.2.7
|
||||||
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
|
'@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
'@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
'@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-context': 1.1.3(@types/react@19.2.7)(react@19.2.3)
|
'@radix-ui/react-context': 1.1.3(@types/react@19.2.7)(react@19.2.3)
|
||||||
@@ -4023,6 +4410,15 @@ snapshots:
|
|||||||
'@types/react': 19.2.7
|
'@types/react': 19.2.7
|
||||||
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
|
'@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
'@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
'@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
@@ -4032,6 +4428,25 @@ snapshots:
|
|||||||
'@types/react': 19.2.7
|
'@types/react': 19.2.7
|
||||||
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
|
'@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/number': 1.1.1
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
'@radix-ui/react-slot@1.2.3(@types/react@19.2.7)(react@19.2.3)':
|
'@radix-ui/react-slot@1.2.3(@types/react@19.2.7)(react@19.2.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
@@ -4077,6 +4492,26 @@ snapshots:
|
|||||||
'@types/react': 19.2.7
|
'@types/react': 19.2.7
|
||||||
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
|
'@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
'@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
'@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@radix-ui/primitive': 1.1.3
|
||||||
@@ -4103,6 +4538,21 @@ snapshots:
|
|||||||
'@types/react': 19.2.7
|
'@types/react': 19.2.7
|
||||||
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
|
'@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@radix-ui/primitive': 1.1.3
|
||||||
@@ -4628,6 +5078,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
fill-range: 7.1.1
|
fill-range: 7.1.1
|
||||||
|
|
||||||
|
browser-image-compression@2.0.2:
|
||||||
|
dependencies:
|
||||||
|
uzip: 0.20201231.0
|
||||||
|
|
||||||
browserslist@4.28.1:
|
browserslist@4.28.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
baseline-browser-mapping: 2.9.11
|
baseline-browser-mapping: 2.9.11
|
||||||
@@ -4794,6 +5248,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
esutils: 2.0.3
|
esutils: 2.0.3
|
||||||
|
|
||||||
|
driver.js@1.4.0: {}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind-apply-helpers: 1.0.2
|
call-bind-apply-helpers: 1.0.2
|
||||||
@@ -5746,6 +6202,69 @@ snapshots:
|
|||||||
|
|
||||||
quickselect@3.0.0: {}
|
quickselect@3.0.0: {}
|
||||||
|
|
||||||
|
radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||||
|
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
react-day-picker@9.13.0(react@19.2.3):
|
react-day-picker@9.13.0(react@19.2.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@date-fns/tz': 1.4.1
|
'@date-fns/tz': 1.4.1
|
||||||
@@ -6236,6 +6755,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.3
|
react: 19.2.3
|
||||||
|
|
||||||
|
uzip@0.20201231.0: {}
|
||||||
|
|
||||||
vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function ForbiddenError() {
|
|||||||
<h2 className="mb-3 text-2xl font-semibold">Forbidden</h2>
|
<h2 className="mb-3 text-2xl font-semibold">Forbidden</h2>
|
||||||
<p>Access to this resource is forbidden. You don't have the necessary permissions to view this page.</p>
|
<p>Access to this resource is forbidden. You don't have the necessary permissions to view this page.</p>
|
||||||
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
|
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
|
||||||
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
|
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back House</Button>
|
||||||
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
|
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
|
||||||
Contact Us
|
Contact Us
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function InternalServerError() {
|
|||||||
<h2 className="mb-3 text-2xl font-semibold">Internal Server Error</h2>
|
<h2 className="mb-3 text-2xl font-semibold">Internal Server Error</h2>
|
||||||
<p>Something went wrong on our end. We're working to fix the issue. Please try again later.</p>
|
<p>Something went wrong on our end. We're working to fix the issue. Please try again later.</p>
|
||||||
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
|
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
|
||||||
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
|
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back House</Button>
|
||||||
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
|
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
|
||||||
Contact Us
|
Contact Us
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function NotFoundError() {
|
|||||||
<h2 className="mb-3 text-2xl font-semibold">Page Not Found</h2>
|
<h2 className="mb-3 text-2xl font-semibold">Page Not Found</h2>
|
||||||
<p>The page you are looking for doesn't exist or has been moved to another location.</p>
|
<p>The page you are looking for doesn't exist or has been moved to another location.</p>
|
||||||
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
|
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
|
||||||
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
|
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back House</Button>
|
||||||
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
|
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
|
||||||
Contact Us
|
Contact Us
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function UnauthorizedError() {
|
|||||||
<h2 className="mb-3 text-2xl font-semibold">Unauthorized</h2>
|
<h2 className="mb-3 text-2xl font-semibold">Unauthorized</h2>
|
||||||
<p>You don't have permission to access this resource. Please sign in or contact your administrator.</p>
|
<p>You don't have permission to access this resource. Please sign in or contact your administrator.</p>
|
||||||
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
|
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
|
||||||
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
|
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back House</Button>
|
||||||
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
|
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
|
||||||
Contact Us
|
Contact Us
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function UnderMaintenanceError() {
|
|||||||
<h2 className="mb-3 text-2xl font-semibold">Under Maintenance</h2>
|
<h2 className="mb-3 text-2xl font-semibold">Under Maintenance</h2>
|
||||||
<p>The service is currently unavailable. Please try again later.</p>
|
<p>The service is currently unavailable. Please try again later.</p>
|
||||||
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
|
<div className='mt-6 flex items-center justify-center gap-4 md:mt-8'>
|
||||||
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
|
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back House</Button>
|
||||||
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
|
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
|
||||||
Contact Us
|
Contact Us
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useActionState } from "react";
|
import { useActionState } from "react";
|
||||||
import { ArrowLeft, Loader2, MailCheck } from "lucide-react";
|
import { ArrowLeft, CircleNotch, EnvelopeOpen } from '@/lib/icons';
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -28,7 +28,7 @@ export function ForgotPasswordForm1({ className, ...props }: React.ComponentProp
|
|||||||
{state.ok ? (
|
{state.ok ? (
|
||||||
<div className="flex flex-col items-center gap-3 py-4 text-center">
|
<div className="flex flex-col items-center gap-3 py-4 text-center">
|
||||||
<div className="bg-primary/10 text-primary flex size-12 items-center justify-center rounded-full">
|
<div className="bg-primary/10 text-primary flex size-12 items-center justify-center rounded-full">
|
||||||
<MailCheck className="size-6" />
|
<EnvelopeOpen className="size-6" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
Bağlantı emailinize gönderildi. Gelen kutusunu kontrol edin.
|
Bağlantı emailinize gönderildi. Gelen kutusunu kontrol edin.
|
||||||
@@ -64,7 +64,7 @@ export function ForgotPasswordForm1({ className, ...props }: React.ComponentProp
|
|||||||
<Button type="submit" className="w-full" disabled={isPending}>
|
<Button type="submit" className="w-full" disabled={isPending}>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<CircleNotch className="size-4 animate-spin" />
|
||||||
Gönderiliyor...
|
Gönderiliyor...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function ForgotPasswordPage() {
|
|||||||
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
|
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
|
||||||
<Logo size={24} />
|
<Logo size={24} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-lg font-semibold">İşletmem</span>
|
<span className="text-lg font-semibold">Emlak CRM</span>
|
||||||
</Link>
|
</Link>
|
||||||
<ForgotPasswordForm1 />
|
<ForgotPasswordForm1 />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function LoginForm3({
|
|||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="sr-only">Login with Apple</span>
|
<span className="sr-only">Login with AppleLogo</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" type="button" className="w-full cursor-pointer">
|
<Button variant="outline" type="button" className="w-full cursor-pointer">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useActionState } from "react";
|
import { useActionState } from "react";
|
||||||
import { Loader2, Building2, Users, Presentation, Zap } from "lucide-react";
|
import { CircleNotch, Buildings, Users, Presentation, Lightning } from '@/lib/icons';
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -43,7 +43,7 @@ export function LoginForm1({
|
|||||||
{/* Logo + Ürün adı */}
|
{/* Logo + Ürün adı */}
|
||||||
<div className="relative z-10 flex items-center gap-3">
|
<div className="relative z-10 flex items-center gap-3">
|
||||||
<div className="flex size-10 items-center justify-center rounded-xl bg-blue-500/20 ring-1 ring-blue-400/30 backdrop-blur">
|
<div className="flex size-10 items-center justify-center rounded-xl bg-blue-500/20 ring-1 ring-blue-400/30 backdrop-blur">
|
||||||
<Building2 className="size-5 text-blue-300" />
|
<Buildings className="size-5 text-blue-300" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-bold tracking-tight leading-none">Emlak CRM</p>
|
<p className="text-lg font-bold tracking-tight leading-none">Emlak CRM</p>
|
||||||
@@ -65,12 +65,12 @@ export function LoginForm1({
|
|||||||
<ul className="space-y-4">
|
<ul className="space-y-4">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
icon: Building2,
|
icon: Buildings,
|
||||||
title: "Portföy yönetimi",
|
title: "Portföy yönetimi",
|
||||||
desc: "Tüm ilanlarınızı fotoğraflarıyla ekleyin, takip edin",
|
desc: "Tüm ilanlarınızı fotoğraflarıyla ekleyin, takip edin",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Zap,
|
icon: Lightning,
|
||||||
title: "Akıllı eşleşme",
|
title: "Akıllı eşleşme",
|
||||||
desc: "Ağırlıklı puanlama ile müşteri × ilan eşleştirmesi",
|
desc: "Ağırlıklı puanlama ile müşteri × ilan eşleştirmesi",
|
||||||
},
|
},
|
||||||
@@ -111,7 +111,7 @@ export function LoginForm1({
|
|||||||
{/* Mobilde logo */}
|
{/* Mobilde logo */}
|
||||||
<div className="flex items-center gap-2 lg:hidden">
|
<div className="flex items-center gap-2 lg:hidden">
|
||||||
<div className="flex size-8 items-center justify-center rounded-lg bg-blue-600 text-white">
|
<div className="flex size-8 items-center justify-center rounded-lg bg-blue-600 text-white">
|
||||||
<Building2 className="size-4" />
|
<Buildings className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold">Emlak CRM</span>
|
<span className="font-bold">Emlak CRM</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,7 +172,7 @@ export function LoginForm1({
|
|||||||
<Button type="submit" className="w-full" disabled={isPending}>
|
<Button type="submit" className="w-full" disabled={isPending}>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<CircleNotch className="size-4 animate-spin" />
|
||||||
Giriş yapılıyor...
|
Giriş yapılıyor...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export function SignupForm3({
|
|||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="sr-only">Sign up with Apple</span>
|
<span className="sr-only">Sign up with AppleLogo</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" type="button" className="w-full cursor-pointer">
|
<Button variant="outline" type="button" className="w-full cursor-pointer">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useActionState } from "react";
|
import { useActionState } from "react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { CircleNotch } from '@/lib/icons';
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
@@ -35,7 +35,7 @@ export function SignupForm1({
|
|||||||
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
|
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
|
||||||
<Logo size={22} />
|
<Logo size={22} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xl font-semibold">İşletmem</span>
|
<span className="text-xl font-semibold">Emlak CRM</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ export function SignupForm1({
|
|||||||
<Button type="submit" className="w-full" disabled={isPending}>
|
<Button type="submit" className="w-full" disabled={isPending}>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<CircleNotch className="size-4 animate-spin" />
|
||||||
Hesap oluşturuluyor...
|
Hesap oluşturuluyor...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -159,22 +159,22 @@ function BrandPanel() {
|
|||||||
<div className="bg-primary-foreground/15 ring-1 ring-primary-foreground/20 backdrop-blur flex size-10 items-center justify-center rounded-md">
|
<div className="bg-primary-foreground/15 ring-1 ring-primary-foreground/20 backdrop-blur flex size-10 items-center justify-center rounded-md">
|
||||||
<Logo size={22} />
|
<Logo size={22} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-lg font-medium">İşletmem</span>
|
<span className="text-lg font-medium">Emlak CRM</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10 flex flex-col gap-3">
|
<div className="relative z-10 flex flex-col gap-3">
|
||||||
<h2 className="text-3xl font-semibold leading-tight">
|
<h2 className="text-3xl font-semibold leading-tight">
|
||||||
İşletmenizi büyütecek tek araç.
|
Emlak ofisinizi büyütecek tek araç.
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-primary-foreground/80 text-sm">
|
<p className="text-primary-foreground/80 text-sm">
|
||||||
Hesap oluşturduktan sonra çalışma alanınızı kuruyor, ekibinizi davet ediyor ve hemen kullanmaya başlıyorsunuz.
|
Hesap oluşturduktan sonra çalışma alanınızı kuruyor, ekibinizi davet ediyor ve hemen kullanmaya başlıyorsunuz.
|
||||||
</p>
|
</p>
|
||||||
<ul className="text-primary-foreground/85 mt-2 space-y-1 text-sm">
|
<ul className="text-primary-foreground/85 mt-2 space-y-1 text-sm">
|
||||||
<li>• Müşteri & hizmet yönetimi</li>
|
<li>• Portföy ve müşteri yönetimi</li>
|
||||||
<li>• Görev ve takvim</li>
|
<li>• Otomatik ilan-müşteri eşleştirme</li>
|
||||||
<li>• Finans ve fatura</li>
|
<li>• Komisyon ve finans takibi</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div className="text-primary-foreground/70 mt-4 text-xs">KovakSoft tarafından</div>
|
<div className="text-primary-foreground/70 mt-4 text-xs">Kovak Yazılım ve Medya LTD. ŞTİ.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { GraduationCap } from '@/lib/icons';
|
||||||
|
import { AcademyClient } from "@/components/academy/academy-client";
|
||||||
|
|
||||||
|
export const metadata = { title: "Akademi | KovakEmlak" };
|
||||||
|
|
||||||
|
export default function AcademyPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6 max-w-5xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="bg-primary/10 text-primary p-2 rounded-lg">
|
||||||
|
<GraduationCap className="size-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Akademi</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Her modülün turunu başlatarak sistemi adım adım öğrenin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AcademyClient />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft, Phone, Envelope, CalendarCheck, Tag } from '@/lib/icons';
|
||||||
|
import { Query } from "node-appwrite";
|
||||||
|
|
||||||
|
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||||
|
import { getCustomer } from "@/lib/appwrite/customer-queries";
|
||||||
|
import {
|
||||||
|
DATABASE_ID, TABLES,
|
||||||
|
CUSTOMER_TYPE_LABELS, CUSTOMER_STAGE_LABELS, CUSTOMER_SOURCE_LABELS,
|
||||||
|
ACTIVITY_TYPE_LABELS, PROPERTY_TYPE_LABELS, LISTING_TYPE_LABELS,
|
||||||
|
type Activity, type CustomerSearch, type PropertyMatch, type Property,
|
||||||
|
} from "@/lib/appwrite/schema";
|
||||||
|
import { createAdminClient } from "@/lib/appwrite/server";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGE_COLORS: Record<string, string> = {
|
||||||
|
ilk_temas: "bg-slate-100 text-slate-700",
|
||||||
|
aktif_arama: "bg-blue-100 text-blue-700",
|
||||||
|
teklif: "bg-amber-100 text-amber-700",
|
||||||
|
sozlesme: "bg-purple-100 text-purple-700",
|
||||||
|
kapandi: "bg-emerald-100 text-emerald-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function CustomerDetailPage({ params }: Props) {
|
||||||
|
const { id } = await params;
|
||||||
|
const ctx = await requireTenant();
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
|
||||||
|
const customer = await getCustomer(id, ctx.tenantId);
|
||||||
|
if (!customer) notFound();
|
||||||
|
|
||||||
|
const [activitiesRes, searchesRes, matchesRes] = await Promise.all([
|
||||||
|
tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.activities,
|
||||||
|
queries: [
|
||||||
|
Query.equal("customerId", id),
|
||||||
|
Query.equal("tenantId", ctx.tenantId),
|
||||||
|
Query.orderDesc("$createdAt"),
|
||||||
|
Query.limit(20),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.customerSearches,
|
||||||
|
queries: [
|
||||||
|
Query.equal("customerId", id),
|
||||||
|
Query.equal("tenantId", ctx.tenantId),
|
||||||
|
Query.limit(20),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.propertyMatches,
|
||||||
|
queries: [
|
||||||
|
Query.equal("customerId", id),
|
||||||
|
Query.equal("tenantId", ctx.tenantId),
|
||||||
|
Query.orderDesc("score"),
|
||||||
|
Query.limit(20),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const activities = JSON.parse(JSON.stringify(activitiesRes.rows)) as Activity[];
|
||||||
|
const searches = JSON.parse(JSON.stringify(searchesRes.rows)) as CustomerSearch[];
|
||||||
|
const matches = JSON.parse(JSON.stringify(matchesRes.rows)) as PropertyMatch[];
|
||||||
|
|
||||||
|
// Fetch matched properties for display
|
||||||
|
const matchedPropertyIds = [...new Set(matches.map((m) => m.propertyId))];
|
||||||
|
const propertiesMap: Record<string, Property> = {};
|
||||||
|
if (matchedPropertyIds.length > 0) {
|
||||||
|
const propsRes = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.properties,
|
||||||
|
queries: [
|
||||||
|
Query.equal("$id", matchedPropertyIds.slice(0, 25)),
|
||||||
|
Query.limit(25),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
for (const row of propsRes.rows) {
|
||||||
|
const p = row as unknown as Property;
|
||||||
|
propertiesMap[p.$id] = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stageKey = customer.stage ?? "ilk_temas";
|
||||||
|
|
||||||
|
function parseJsonList(json?: string | null): string[] {
|
||||||
|
if (!json) return [];
|
||||||
|
try { return JSON.parse(json) as string[]; } catch { return [json]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6 max-w-5xl">
|
||||||
|
{/* Back */}
|
||||||
|
<div>
|
||||||
|
<Link href="/customers" className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-sm">
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
Müşteriler
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-bold">{customer.name}</h1>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Badge variant="outline">{CUSTOMER_TYPE_LABELS[customer.type] ?? customer.type}</Badge>
|
||||||
|
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${STAGE_COLORS[stageKey] ?? STAGE_COLORS.ilk_temas}`}>
|
||||||
|
{CUSTOMER_STAGE_LABELS[stageKey as keyof typeof CUSTOMER_STAGE_LABELS] ?? stageKey}
|
||||||
|
</span>
|
||||||
|
{customer.source && (
|
||||||
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Tag className="size-3" />
|
||||||
|
{CUSTOMER_SOURCE_LABELS[customer.source] ?? customer.source}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/customers"
|
||||||
|
className="text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Düzenle →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
|
{/* Left: info + searches + activities */}
|
||||||
|
<div className="md:col-span-2 space-y-6">
|
||||||
|
{/* Contact card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm">İletişim</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{customer.phone && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Phone className="size-4 text-muted-foreground shrink-0" />
|
||||||
|
<a href={`tel:${customer.phone}`} className="hover:underline">{customer.phone}</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{customer.email && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Envelope className="size-4 text-muted-foreground shrink-0" />
|
||||||
|
<a href={`mailto:${customer.email}`} className="hover:underline">{customer.email}</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{customer.nextFollowUpDate && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<CalendarCheck className="size-4 text-muted-foreground shrink-0" />
|
||||||
|
<span>Takip: {new Date(customer.nextFollowUpDate).toLocaleDateString("tr-TR", { day: "numeric", month: "long", year: "numeric" })}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!customer.phone && !customer.email && !customer.nextFollowUpDate && (
|
||||||
|
<p className="text-sm text-muted-foreground">İletişim bilgisi yok.</p>
|
||||||
|
)}
|
||||||
|
{customer.notes && (
|
||||||
|
<div className="pt-2 border-t">
|
||||||
|
<p className="text-xs text-muted-foreground whitespace-pre-wrap">{customer.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* MagnifyingGlass criteria */}
|
||||||
|
{searches.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm">Arama Kriterleri ({searches.length})</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{searches.map((s) => (
|
||||||
|
<div key={s.$id} className="rounded-lg border p-3 text-sm space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-medium">
|
||||||
|
{s.listingType ? (s.listingType === "satilik" ? "Satılık" : "Kiralık") : "Tümü"}
|
||||||
|
{parseJsonList(s.propertyTypes).length > 0 && ` · ${parseJsonList(s.propertyTypes).join(", ")}`}
|
||||||
|
</span>
|
||||||
|
<Badge variant={s.isActive ? "default" : "secondary"} className="text-xs">
|
||||||
|
{s.isActive ? "Aktif" : "Pasif"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
{parseJsonList(s.roomCounts).length > 0 && (
|
||||||
|
<span>Oda: {parseJsonList(s.roomCounts).join(", ")}</span>
|
||||||
|
)}
|
||||||
|
{(s.minPrice || s.maxPrice) && (
|
||||||
|
<span>Fiyat: {s.minPrice ? s.minPrice.toLocaleString("tr-TR") : "–"} – {s.maxPrice ? s.maxPrice.toLocaleString("tr-TR") : "–"} ₺</span>
|
||||||
|
)}
|
||||||
|
{(s.minM2 || s.maxM2) && (
|
||||||
|
<span>m²: {s.minM2 ?? "–"}–{s.maxM2 ?? "–"}</span>
|
||||||
|
)}
|
||||||
|
{parseJsonList(s.cities).length > 0 && (
|
||||||
|
<span>Şehir: {parseJsonList(s.cities).join(", ")}</span>
|
||||||
|
)}
|
||||||
|
{parseJsonList(s.districts).length > 0 && (
|
||||||
|
<span>İlçe: {parseJsonList(s.districts).join(", ")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{s.notes && <p className="text-xs text-muted-foreground italic">{s.notes}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Activities */}
|
||||||
|
{activities.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm">Aktiviteler ({activities.length})</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="divide-y px-0">
|
||||||
|
{activities.map((a) => (
|
||||||
|
<div key={a.$id} className="flex items-start gap-3 px-6 py-3 text-sm">
|
||||||
|
<div className="mt-0.5">
|
||||||
|
<Badge variant="outline" className="text-xs px-1.5 py-0">
|
||||||
|
{ACTIVITY_TYPE_LABELS[a.type] ?? a.type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium truncate">{a.title}</p>
|
||||||
|
{a.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{a.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">
|
||||||
|
{new Date(a.$createdAt).toLocaleDateString("tr-TR")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: matches */}
|
||||||
|
<div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm">
|
||||||
|
Eşleşen İlanlar
|
||||||
|
{matches.length > 0 && (
|
||||||
|
<span className="ml-1.5 text-muted-foreground font-normal text-xs">({matches.length})</span>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{matches.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">Henüz eşleşme yok.</p>
|
||||||
|
) : (
|
||||||
|
matches.map((m) => {
|
||||||
|
const p = propertiesMap[m.propertyId];
|
||||||
|
const score = m.score ?? 0;
|
||||||
|
const scoreColor =
|
||||||
|
score >= 80 ? "text-green-600" : score >= 60 ? "text-blue-600" : score >= 40 ? "text-amber-600" : "text-gray-400";
|
||||||
|
return (
|
||||||
|
<div key={m.$id} className="rounded-lg border p-2.5 text-sm space-y-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="font-medium truncate text-xs leading-snug">
|
||||||
|
{p?.title ?? m.propertyId}
|
||||||
|
</p>
|
||||||
|
<span className={`text-xs font-bold ${scoreColor}`}>{score}</span>
|
||||||
|
</div>
|
||||||
|
{p && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{PROPERTY_TYPE_LABELS[p.propertyType] ?? p.propertyType}
|
||||||
|
{p.city ? ` · ${p.city}` : ""}
|
||||||
|
{p.price ? ` · ${p.price.toLocaleString("tr-TR")} ₺` : ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className={`text-[10px] ${m.notified ? "text-muted-foreground" : "text-amber-600 font-medium"}`}>
|
||||||
|
{m.notified ? "Bildirildi" : "Bekliyor"}
|
||||||
|
</span>
|
||||||
|
{p && (
|
||||||
|
<Link href={`/properties/${p.$id}`} className="text-[10px] text-primary hover:underline">
|
||||||
|
Detay →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { IconContext } from "@phosphor-icons/react";
|
||||||
|
|
||||||
import { AppSidebar } from "@/components/app-sidebar";
|
import { AppSidebar } from "@/components/app-sidebar";
|
||||||
import { SiteHeader } from "@/components/site-header";
|
import { SiteHeader } from "@/components/site-header";
|
||||||
@@ -28,16 +29,19 @@ export function DashboardShell({
|
|||||||
company,
|
company,
|
||||||
children,
|
children,
|
||||||
initialPrefs,
|
initialPrefs,
|
||||||
|
pendingMatchCount = 0,
|
||||||
}: {
|
}: {
|
||||||
user: ShellUser;
|
user: ShellUser;
|
||||||
company: ShellCompany;
|
company: ShellCompany;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
initialPrefs: ThemePrefs;
|
initialPrefs: ThemePrefs;
|
||||||
|
pendingMatchCount?: number;
|
||||||
}) {
|
}) {
|
||||||
const [themeCustomizerOpen, setThemeCustomizerOpen] = React.useState(false);
|
const [themeCustomizerOpen, setThemeCustomizerOpen] = React.useState(false);
|
||||||
const { config } = useSidebarConfig();
|
const { config } = useSidebarConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<IconContext.Provider value={{ weight: "bold" }}>
|
||||||
<SidebarProvider
|
<SidebarProvider
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
@@ -58,14 +62,11 @@ export function DashboardShell({
|
|||||||
variant={config.variant}
|
variant={config.variant}
|
||||||
collapsible={config.collapsible}
|
collapsible={config.collapsible}
|
||||||
side={config.side}
|
side={config.side}
|
||||||
|
pendingMatchCount={pendingMatchCount}
|
||||||
/>
|
/>
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<SiteHeader company={company} />
|
<SiteHeader company={company} />
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col min-h-0">{children}</div>
|
||||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
|
||||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</>
|
</>
|
||||||
@@ -73,11 +74,7 @@ export function DashboardShell({
|
|||||||
<>
|
<>
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<SiteHeader company={company} />
|
<SiteHeader company={company} />
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col min-h-0">{children}</div>
|
||||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
|
||||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
<AppSidebar
|
<AppSidebar
|
||||||
@@ -86,6 +83,7 @@ export function DashboardShell({
|
|||||||
variant={config.variant}
|
variant={config.variant}
|
||||||
collapsible={config.collapsible}
|
collapsible={config.collapsible}
|
||||||
side={config.side}
|
side={config.side}
|
||||||
|
pendingMatchCount={pendingMatchCount}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -97,5 +95,6 @@ export function DashboardShell({
|
|||||||
initialPrefs={initialPrefs}
|
initialPrefs={initialPrefs}
|
||||||
/>
|
/>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
</IconContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,291 +1,232 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import { useState, useMemo } from "react";
|
||||||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
|
import {
|
||||||
|
Line, LineChart, CartesianGrid, XAxis, YAxis, Pie, PieChart, Cell,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card, CardContent, CardDescription, CardHeader, CardTitle,
|
||||||
CardAction,
|
} from "@/components/ui/card";
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card"
|
|
||||||
import {
|
import {
|
||||||
type ChartConfig,
|
ChartContainer, ChartTooltip, type ChartConfig,
|
||||||
ChartContainer,
|
} from "@/components/ui/chart";
|
||||||
ChartTooltip,
|
|
||||||
ChartTooltipContent,
|
|
||||||
} from "@/components/ui/chart"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import {
|
|
||||||
ToggleGroup,
|
|
||||||
ToggleGroupItem,
|
|
||||||
} from "@/components/ui/toggle-group"
|
|
||||||
|
|
||||||
export const description = "An interactive area chart"
|
type View = "trend" | "dagilim";
|
||||||
|
|
||||||
const chartData = [
|
const PIE_COLORS = [
|
||||||
{ date: "2024-04-01", desktop: 222, mobile: 150 },
|
"hsl(221, 83%, 53%)",
|
||||||
{ date: "2024-04-02", desktop: 97, mobile: 180 },
|
"hsl(142, 71%, 45%)",
|
||||||
{ date: "2024-04-03", desktop: 167, mobile: 120 },
|
"hsl(38, 92%, 50%)",
|
||||||
{ date: "2024-04-04", desktop: 242, mobile: 260 },
|
"hsl(280, 68%, 58%)",
|
||||||
{ date: "2024-04-05", desktop: 373, mobile: 290 },
|
"hsl(10, 80%, 55%)",
|
||||||
{ date: "2024-04-06", desktop: 301, mobile: 340 },
|
"hsl(200, 65%, 50%)",
|
||||||
{ date: "2024-04-07", desktop: 245, mobile: 180 },
|
];
|
||||||
{ date: "2024-04-08", desktop: 409, mobile: 320 },
|
|
||||||
{ date: "2024-04-09", desktop: 59, mobile: 110 },
|
|
||||||
{ date: "2024-04-10", desktop: 261, mobile: 190 },
|
|
||||||
{ date: "2024-04-11", desktop: 327, mobile: 350 },
|
|
||||||
{ date: "2024-04-12", desktop: 292, mobile: 210 },
|
|
||||||
{ date: "2024-04-13", desktop: 342, mobile: 380 },
|
|
||||||
{ date: "2024-04-14", desktop: 137, mobile: 220 },
|
|
||||||
{ date: "2024-04-15", desktop: 120, mobile: 170 },
|
|
||||||
{ date: "2024-04-16", desktop: 138, mobile: 190 },
|
|
||||||
{ date: "2024-04-17", desktop: 446, mobile: 360 },
|
|
||||||
{ date: "2024-04-18", desktop: 364, mobile: 410 },
|
|
||||||
{ date: "2024-04-19", desktop: 243, mobile: 180 },
|
|
||||||
{ date: "2024-04-20", desktop: 89, mobile: 150 },
|
|
||||||
{ date: "2024-04-21", desktop: 137, mobile: 200 },
|
|
||||||
{ date: "2024-04-22", desktop: 224, mobile: 170 },
|
|
||||||
{ date: "2024-04-23", desktop: 138, mobile: 230 },
|
|
||||||
{ date: "2024-04-24", desktop: 387, mobile: 290 },
|
|
||||||
{ date: "2024-04-25", desktop: 215, mobile: 250 },
|
|
||||||
{ date: "2024-04-26", desktop: 75, mobile: 130 },
|
|
||||||
{ date: "2024-04-27", desktop: 383, mobile: 420 },
|
|
||||||
{ date: "2024-04-28", desktop: 122, mobile: 180 },
|
|
||||||
{ date: "2024-04-29", desktop: 315, mobile: 240 },
|
|
||||||
{ date: "2024-04-30", desktop: 454, mobile: 380 },
|
|
||||||
{ date: "2024-05-01", desktop: 165, mobile: 220 },
|
|
||||||
{ date: "2024-05-02", desktop: 293, mobile: 310 },
|
|
||||||
{ date: "2024-05-03", desktop: 247, mobile: 190 },
|
|
||||||
{ date: "2024-05-04", desktop: 385, mobile: 420 },
|
|
||||||
{ date: "2024-05-05", desktop: 481, mobile: 390 },
|
|
||||||
{ date: "2024-05-06", desktop: 498, mobile: 520 },
|
|
||||||
{ date: "2024-05-07", desktop: 388, mobile: 300 },
|
|
||||||
{ date: "2024-05-08", desktop: 149, mobile: 210 },
|
|
||||||
{ date: "2024-05-09", desktop: 227, mobile: 180 },
|
|
||||||
{ date: "2024-05-10", desktop: 293, mobile: 330 },
|
|
||||||
{ date: "2024-05-11", desktop: 335, mobile: 270 },
|
|
||||||
{ date: "2024-05-12", desktop: 197, mobile: 240 },
|
|
||||||
{ date: "2024-05-13", desktop: 197, mobile: 160 },
|
|
||||||
{ date: "2024-05-14", desktop: 448, mobile: 490 },
|
|
||||||
{ date: "2024-05-15", desktop: 473, mobile: 380 },
|
|
||||||
{ date: "2024-05-16", desktop: 338, mobile: 400 },
|
|
||||||
{ date: "2024-05-17", desktop: 499, mobile: 420 },
|
|
||||||
{ date: "2024-05-18", desktop: 315, mobile: 350 },
|
|
||||||
{ date: "2024-05-19", desktop: 235, mobile: 180 },
|
|
||||||
{ date: "2024-05-20", desktop: 177, mobile: 230 },
|
|
||||||
{ date: "2024-05-21", desktop: 82, mobile: 140 },
|
|
||||||
{ date: "2024-05-22", desktop: 81, mobile: 120 },
|
|
||||||
{ date: "2024-05-23", desktop: 252, mobile: 290 },
|
|
||||||
{ date: "2024-05-24", desktop: 294, mobile: 220 },
|
|
||||||
{ date: "2024-05-25", desktop: 201, mobile: 250 },
|
|
||||||
{ date: "2024-05-26", desktop: 213, mobile: 170 },
|
|
||||||
{ date: "2024-05-27", desktop: 420, mobile: 460 },
|
|
||||||
{ date: "2024-05-28", desktop: 233, mobile: 190 },
|
|
||||||
{ date: "2024-05-29", desktop: 78, mobile: 130 },
|
|
||||||
{ date: "2024-05-30", desktop: 340, mobile: 280 },
|
|
||||||
{ date: "2024-05-31", desktop: 178, mobile: 230 },
|
|
||||||
{ date: "2024-06-01", desktop: 178, mobile: 200 },
|
|
||||||
{ date: "2024-06-02", desktop: 470, mobile: 410 },
|
|
||||||
{ date: "2024-06-03", desktop: 103, mobile: 160 },
|
|
||||||
{ date: "2024-06-04", desktop: 439, mobile: 380 },
|
|
||||||
{ date: "2024-06-05", desktop: 88, mobile: 140 },
|
|
||||||
{ date: "2024-06-06", desktop: 294, mobile: 250 },
|
|
||||||
{ date: "2024-06-07", desktop: 323, mobile: 370 },
|
|
||||||
{ date: "2024-06-08", desktop: 385, mobile: 320 },
|
|
||||||
{ date: "2024-06-09", desktop: 438, mobile: 480 },
|
|
||||||
{ date: "2024-06-10", desktop: 155, mobile: 200 },
|
|
||||||
{ date: "2024-06-11", desktop: 92, mobile: 150 },
|
|
||||||
{ date: "2024-06-12", desktop: 492, mobile: 420 },
|
|
||||||
{ date: "2024-06-13", desktop: 81, mobile: 130 },
|
|
||||||
{ date: "2024-06-14", desktop: 426, mobile: 380 },
|
|
||||||
{ date: "2024-06-15", desktop: 307, mobile: 350 },
|
|
||||||
{ date: "2024-06-16", desktop: 371, mobile: 310 },
|
|
||||||
{ date: "2024-06-17", desktop: 475, mobile: 520 },
|
|
||||||
{ date: "2024-06-18", desktop: 107, mobile: 170 },
|
|
||||||
{ date: "2024-06-19", desktop: 341, mobile: 290 },
|
|
||||||
{ date: "2024-06-20", desktop: 408, mobile: 450 },
|
|
||||||
{ date: "2024-06-21", desktop: 169, mobile: 210 },
|
|
||||||
{ date: "2024-06-22", desktop: 317, mobile: 270 },
|
|
||||||
{ date: "2024-06-23", desktop: 480, mobile: 530 },
|
|
||||||
{ date: "2024-06-24", desktop: 132, mobile: 180 },
|
|
||||||
{ date: "2024-06-25", desktop: 141, mobile: 190 },
|
|
||||||
{ date: "2024-06-26", desktop: 434, mobile: 380 },
|
|
||||||
{ date: "2024-06-27", desktop: 448, mobile: 490 },
|
|
||||||
{ date: "2024-06-28", desktop: 149, mobile: 200 },
|
|
||||||
{ date: "2024-06-29", desktop: 103, mobile: 160 },
|
|
||||||
{ date: "2024-06-30", desktop: 446, mobile: 400 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const chartConfig = {
|
const chartConfig = {
|
||||||
visitors: {
|
ilanSayisi: { label: "İlan", color: "hsl(221, 83%, 53%)" },
|
||||||
label: "Visitors",
|
musteriSayisi: { label: "Müşteri", color: "hsl(142, 71%, 45%)" },
|
||||||
},
|
aktiviteSayisi: { label: "Aktivite", color: "hsl(38, 92%, 50%)" },
|
||||||
desktop: {
|
} satisfies ChartConfig;
|
||||||
label: "Desktop",
|
|
||||||
color: "var(--primary)",
|
|
||||||
},
|
|
||||||
mobile: {
|
|
||||||
label: "Mobile",
|
|
||||||
color: "var(--primary)",
|
|
||||||
},
|
|
||||||
} satisfies ChartConfig
|
|
||||||
|
|
||||||
export function ChartAreaInteractive() {
|
const SERIES = [
|
||||||
const isMobile = useIsMobile()
|
{ dataKey: "ilanSayisi", label: "İlan", color: "hsl(221, 83%, 53%)" },
|
||||||
const [timeRange, setTimeRange] = React.useState("90d")
|
{ dataKey: "musteriSayisi", label: "Müşteri", color: "hsl(142, 71%, 45%)" },
|
||||||
|
{ dataKey: "aktiviteSayisi", label: "Aktivite", color: "hsl(38, 92%, 50%)" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
React.useEffect(() => {
|
export function ChartAreaInteractive({
|
||||||
if (isMobile) {
|
ilanTrend, musteriTrend, aktiviteTrend, portfoyDagilim,
|
||||||
setTimeRange("7d")
|
}: {
|
||||||
}
|
ilanTrend: { ay: string; ilanSayisi: number }[];
|
||||||
}, [isMobile])
|
musteriTrend: { ay: string; musteriSayisi: number }[];
|
||||||
|
aktiviteTrend: { ay: string; aktiviteSayisi: number }[];
|
||||||
|
portfoyDagilim: { tip: string; label: string; sayi: number }[];
|
||||||
|
}) {
|
||||||
|
const [view, setView] = useState<View>("trend");
|
||||||
|
|
||||||
const filteredData = chartData.filter((item) => {
|
const trendData = useMemo(() =>
|
||||||
const date = new Date(item.date)
|
ilanTrend.map((item, i) => ({
|
||||||
const referenceDate = new Date("2024-06-30")
|
ay: item.ay,
|
||||||
let daysToSubtract = 90
|
ilanSayisi: item.ilanSayisi,
|
||||||
if (timeRange === "30d") {
|
musteriSayisi: musteriTrend[i]?.musteriSayisi ?? 0,
|
||||||
daysToSubtract = 30
|
aktiviteSayisi: aktiviteTrend[i]?.aktiviteSayisi ?? 0,
|
||||||
} else if (timeRange === "7d") {
|
})),
|
||||||
daysToSubtract = 7
|
[ilanTrend, musteriTrend, aktiviteTrend]
|
||||||
}
|
);
|
||||||
const startDate = new Date(referenceDate)
|
|
||||||
startDate.setDate(startDate.getDate() - daysToSubtract)
|
const dagilimTotal = portfoyDagilim.reduce((s, d) => s + d.sayi, 0);
|
||||||
return date >= startDate
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="@container/card">
|
/*
|
||||||
<CardHeader>
|
* Mobil: kart doğal yüksekliğinde (overflow: hidden her iki eksen).
|
||||||
<CardTitle>Total Visitors</CardTitle>
|
* Desktop: lg:h-full ile grid item'ı tamamen doldurur (items-stretch ile eşit yükseklik).
|
||||||
<CardDescription>
|
*/
|
||||||
<span className="hidden @[540px]/card:block">
|
<Card className="overflow-hidden flex flex-col lg:h-full">
|
||||||
Total for the last 3 months
|
<CardHeader className="pb-2 shrink-0">
|
||||||
</span>
|
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||||
<span className="@[540px]/card:hidden">Last 3 months</span>
|
<div>
|
||||||
</CardDescription>
|
<CardTitle>Portföy Analitiği</CardTitle>
|
||||||
<CardAction>
|
<CardDescription>
|
||||||
<ToggleGroup
|
{view === "trend"
|
||||||
type="single"
|
? "Son 6 ay — ilan, müşteri ve aktivite"
|
||||||
value={timeRange}
|
: "Aktif portföy dağılımı"}
|
||||||
onValueChange={setTimeRange}
|
</CardDescription>
|
||||||
variant="outline"
|
</div>
|
||||||
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
|
<div className="flex rounded-lg border text-xs overflow-hidden shrink-0">
|
||||||
>
|
{(["trend", "dagilim"] as View[]).map((v, i) => (
|
||||||
<ToggleGroupItem value="90d">Last 3 months</ToggleGroupItem>
|
<button
|
||||||
<ToggleGroupItem value="30d">Last 30 days</ToggleGroupItem>
|
key={v}
|
||||||
<ToggleGroupItem value="7d">Last 7 days</ToggleGroupItem>
|
onClick={() => setView(v)}
|
||||||
</ToggleGroup>
|
className={`px-3 py-1.5 font-medium transition-colors ${i > 0 ? "border-l" : ""} ${
|
||||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
view === v
|
||||||
<SelectTrigger
|
? "bg-primary text-primary-foreground"
|
||||||
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
|
: "hover:bg-muted text-muted-foreground"
|
||||||
size="sm"
|
}`}
|
||||||
aria-label="Select a value"
|
>
|
||||||
>
|
{v === "trend" ? "Trend" : "Dağılım"}
|
||||||
<SelectValue placeholder="Last 3 months" />
|
</button>
|
||||||
</SelectTrigger>
|
))}
|
||||||
<SelectContent className="rounded-xl">
|
</div>
|
||||||
<SelectItem value="90d" className="rounded-lg">
|
</div>
|
||||||
Last 3 months
|
{view === "trend" && (
|
||||||
</SelectItem>
|
<div className="flex gap-3 mt-1">
|
||||||
<SelectItem value="30d" className="rounded-lg">
|
{SERIES.map((s) => (
|
||||||
Last 30 days
|
<span key={s.dataKey} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
</SelectItem>
|
<span className="inline-block size-2 rounded-full shrink-0" style={{ backgroundColor: s.color }} />
|
||||||
<SelectItem value="7d" className="rounded-lg">
|
{s.label}
|
||||||
Last 7 days
|
</span>
|
||||||
</SelectItem>
|
))}
|
||||||
</SelectContent>
|
</div>
|
||||||
</Select>
|
)}
|
||||||
</CardAction>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
|
||||||
<ChartContainer
|
{/*
|
||||||
config={chartConfig}
|
* CardContent:
|
||||||
className="aspect-auto h-[250px] w-full"
|
* - Mobil: padding normal, yükseklik = içerik (chart 180px sabit).
|
||||||
>
|
* - Desktop: lg:flex-1 lg:min-h-0 ile kalan alanı doldurur.
|
||||||
<AreaChart data={filteredData}>
|
* min-h-0 zorunlu: flex-1 tek başına overflow'u engellemez.
|
||||||
<defs>
|
*/}
|
||||||
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
|
<CardContent className="px-2 sm:px-4 pb-4 lg:flex-1 lg:min-h-0">
|
||||||
<stop
|
{view === "trend" ? (
|
||||||
offset="5%"
|
/*
|
||||||
stopColor="var(--color-desktop)"
|
* h-[180px]: mobilde sabit yükseklik → Recharts collapse etmez.
|
||||||
stopOpacity={1.0}
|
* lg:h-full: desktop'ta CardContent'in tamamını doldurur.
|
||||||
|
*/
|
||||||
|
<ChartContainer config={chartConfig} className="h-[180px] lg:h-full w-full">
|
||||||
|
<LineChart data={trendData} margin={{ top: 8, right: 8, left: -20, bottom: 0 }}>
|
||||||
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="ay"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
allowDecimals={false}
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={({ active, payload, label }) => {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-background px-3 py-2 text-sm shadow-md space-y-1">
|
||||||
|
<p className="font-medium text-xs text-muted-foreground mb-1.5">{label}</p>
|
||||||
|
{payload.map((p) => {
|
||||||
|
const s = SERIES.find((x) => x.dataKey === p.dataKey);
|
||||||
|
return (
|
||||||
|
<div key={p.dataKey} className="flex items-center gap-2">
|
||||||
|
<span className="size-2 rounded-full shrink-0" style={{ backgroundColor: s?.color }} />
|
||||||
|
<span className="flex-1">{s?.label}</span>
|
||||||
|
<span className="font-semibold tabular-nums">{p.value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{SERIES.map((s) => (
|
||||||
|
<Line
|
||||||
|
key={s.dataKey}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={s.dataKey}
|
||||||
|
stroke={s.color}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 3, fill: s.color, strokeWidth: 0 }}
|
||||||
|
activeDot={{ r: 5, fill: s.color, strokeWidth: 0 }}
|
||||||
/>
|
/>
|
||||||
<stop
|
))}
|
||||||
offset="95%"
|
</LineChart>
|
||||||
stopColor="var(--color-desktop)"
|
</ChartContainer>
|
||||||
stopOpacity={0.1}
|
) : (
|
||||||
/>
|
<div className="flex flex-col sm:flex-row gap-6 items-center h-[180px] lg:h-full">
|
||||||
</linearGradient>
|
{portfoyDagilim.length === 0 ? (
|
||||||
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
|
<p className="text-sm text-muted-foreground w-full text-center py-8">
|
||||||
<stop
|
Henüz aktif ilan yok
|
||||||
offset="5%"
|
</p>
|
||||||
stopColor="var(--color-mobile)"
|
) : (
|
||||||
stopOpacity={0.8}
|
<>
|
||||||
/>
|
<div className="shrink-0">
|
||||||
<stop
|
<PieChart width={150} height={150}>
|
||||||
offset="95%"
|
<Pie
|
||||||
stopColor="var(--color-mobile)"
|
data={portfoyDagilim}
|
||||||
stopOpacity={0.1}
|
dataKey="sayi"
|
||||||
/>
|
nameKey="label"
|
||||||
</linearGradient>
|
cx={75} cy={75}
|
||||||
</defs>
|
innerRadius={46}
|
||||||
<CartesianGrid vertical={false} />
|
outerRadius={68}
|
||||||
<XAxis
|
paddingAngle={2}
|
||||||
dataKey="date"
|
strokeWidth={0}
|
||||||
tickLine={false}
|
>
|
||||||
axisLine={false}
|
{portfoyDagilim.map((_, i) => (
|
||||||
tickMargin={8}
|
<Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />
|
||||||
minTickGap={32}
|
))}
|
||||||
tickFormatter={(value) => {
|
</Pie>
|
||||||
const date = new Date(value)
|
<ChartTooltip
|
||||||
return date.toLocaleDateString("en-US", {
|
content={({ active, payload }) => {
|
||||||
month: "short",
|
if (!active || !payload?.[0]) return null;
|
||||||
day: "numeric",
|
const d = payload[0].payload as { label: string; sayi: number };
|
||||||
})
|
const pct = dagilimTotal > 0 ? Math.round((d.sayi / dagilimTotal) * 100) : 0;
|
||||||
}}
|
return (
|
||||||
/>
|
<div className="rounded-lg border bg-background px-3 py-2 text-sm shadow-sm">
|
||||||
<ChartTooltip
|
<p className="font-medium">{d.label}</p>
|
||||||
cursor={false}
|
<p className="text-muted-foreground">{d.sayi} ilan · %{pct}</p>
|
||||||
content={
|
</div>
|
||||||
<ChartTooltipContent
|
);
|
||||||
labelFormatter={(value) => {
|
}}
|
||||||
return new Date(value as string | number | Date).toLocaleDateString("en-US", {
|
/>
|
||||||
month: "short",
|
</PieChart>
|
||||||
day: "numeric",
|
</div>
|
||||||
})
|
<div className="flex-1 min-w-0 w-full space-y-2.5 overflow-y-auto">
|
||||||
}}
|
{portfoyDagilim.map((d, i) => {
|
||||||
indicator="dot"
|
const pct = dagilimTotal > 0 ? Math.round((d.sayi / dagilimTotal) * 100) : 0;
|
||||||
/>
|
return (
|
||||||
}
|
<div key={d.tip}>
|
||||||
/>
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Area
|
<span className="size-2.5 rounded-full shrink-0" style={{ backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }} />
|
||||||
dataKey="mobile"
|
<span className="text-sm flex-1 min-w-0 truncate">{d.label}</span>
|
||||||
type="natural"
|
<span className="text-sm font-semibold tabular-nums">{d.sayi}</span>
|
||||||
fill="url(#fillMobile)"
|
<span className="text-xs text-muted-foreground w-8 text-right">%{pct}</span>
|
||||||
stroke="var(--color-mobile)"
|
</div>
|
||||||
stackId="a"
|
<div className="ml-4 h-1 rounded-full bg-muted overflow-hidden">
|
||||||
/>
|
<div
|
||||||
<Area
|
className="h-full rounded-full transition-all"
|
||||||
dataKey="desktop"
|
style={{ width: `${pct}%`, backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }}
|
||||||
type="natural"
|
/>
|
||||||
fill="url(#fillDesktop)"
|
</div>
|
||||||
stroke="var(--color-desktop)"
|
</div>
|
||||||
stackId="a"
|
);
|
||||||
/>
|
})}
|
||||||
</AreaChart>
|
</div>
|
||||||
</ChartContainer>
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState, useCallback } from "react";
|
||||||
|
|
||||||
|
export function DashboardCarousel({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
count,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className: string;
|
||||||
|
count: number;
|
||||||
|
}) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
|
||||||
|
const onScroll = useCallback(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const total = el.scrollWidth - el.clientWidth;
|
||||||
|
if (total <= 0) return;
|
||||||
|
setActive(Math.round((el.scrollLeft / total) * (count - 1)));
|
||||||
|
}, [count]);
|
||||||
|
|
||||||
|
const scrollTo = (i: number) => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const total = el.scrollWidth - el.clientWidth;
|
||||||
|
el.scrollTo({ left: (total / (count - 1)) * i, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div ref={scrollRef} onScroll={onScroll} className={className}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center gap-1.5 mt-2 lg:hidden">
|
||||||
|
{Array.from({ length: count }, (_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
aria-label={`Bölüm ${i + 1}`}
|
||||||
|
onClick={() => scrollTo(i)}
|
||||||
|
className={`h-1.5 rounded-full transition-all duration-200 ${
|
||||||
|
i === active
|
||||||
|
? "w-4 bg-primary"
|
||||||
|
: "w-1.5 bg-muted-foreground/30 hover:bg-muted-foreground/50"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,19 +21,19 @@ import {
|
|||||||
} from "@dnd-kit/sortable"
|
} from "@dnd-kit/sortable"
|
||||||
import { CSS } from "@dnd-kit/utilities"
|
import { CSS } from "@dnd-kit/utilities"
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
CaretDown,
|
||||||
ChevronLeft,
|
CaretLeft,
|
||||||
ChevronRight,
|
CaretRight,
|
||||||
ChevronsLeft,
|
CaretDoubleLeft,
|
||||||
ChevronsRight,
|
CaretDoubleRight,
|
||||||
CircleCheckBig,
|
CheckCircle,
|
||||||
EllipsisVertical,
|
DotsThreeVertical,
|
||||||
GripVertical,
|
DotsSixVertical,
|
||||||
Columns2,
|
Columns,
|
||||||
Loader,
|
CircleNotch,
|
||||||
Plus,
|
Plus,
|
||||||
TrendingUp,
|
TrendUp,
|
||||||
} from "lucide-react"
|
} from '@/lib/icons'
|
||||||
import {
|
import {
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
type ColumnFiltersState,
|
type ColumnFiltersState,
|
||||||
@@ -121,7 +121,7 @@ function DragHandle({ id }: { id: number }) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="text-muted-foreground size-7 hover:bg-transparent cursor-move"
|
className="text-muted-foreground size-7 hover:bg-transparent cursor-move"
|
||||||
>
|
>
|
||||||
<GripVertical className="text-muted-foreground size-3" />
|
<DotsSixVertical className="text-muted-foreground size-3" />
|
||||||
<span className="sr-only">Drag to reorder</span>
|
<span className="sr-only">Drag to reorder</span>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
@@ -184,9 +184,9 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge variant="outline" className="text-muted-foreground px-1.5">
|
<Badge variant="outline" className="text-muted-foreground px-1.5">
|
||||||
{row.original.status === "Done" ? (
|
{row.original.status === "Done" ? (
|
||||||
<CircleCheckBig className="text-green-500 dark:text-green-400" />
|
<CheckCircle className="text-green-500 dark:text-green-400" />
|
||||||
) : (
|
) : (
|
||||||
<Loader />
|
<CircleNotch />
|
||||||
)}
|
)}
|
||||||
{row.original.status}
|
{row.original.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -286,7 +286,7 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
|||||||
className="data-[state=open]:bg-muted text-muted-foreground flex size-8 cursor-pointer"
|
className="data-[state=open]:bg-muted text-muted-foreground flex size-8 cursor-pointer"
|
||||||
size="icon"
|
size="icon"
|
||||||
>
|
>
|
||||||
<EllipsisVertical />
|
<DotsThreeVertical />
|
||||||
<span className="sr-only">Open menu</span>
|
<span className="sr-only">Open menu</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -629,7 +629,7 @@ export function DataTable({
|
|||||||
disabled={!currentTable.getCanPreviousPage()}
|
disabled={!currentTable.getCanPreviousPage()}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Go to first page</span>
|
<span className="sr-only">Go to first page</span>
|
||||||
<ChevronsLeft />
|
<CaretDoubleLeft />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -639,7 +639,7 @@ export function DataTable({
|
|||||||
disabled={!currentTable.getCanPreviousPage()}
|
disabled={!currentTable.getCanPreviousPage()}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Go to previous page</span>
|
<span className="sr-only">Go to previous page</span>
|
||||||
<ChevronLeft />
|
<CaretLeft />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -649,7 +649,7 @@ export function DataTable({
|
|||||||
disabled={!currentTable.getCanNextPage()}
|
disabled={!currentTable.getCanNextPage()}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Go to next page</span>
|
<span className="sr-only">Go to next page</span>
|
||||||
<ChevronRight />
|
<CaretRight />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -659,7 +659,7 @@ export function DataTable({
|
|||||||
disabled={!currentTable.getCanNextPage()}
|
disabled={!currentTable.getCanNextPage()}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Go to last page</span>
|
<span className="sr-only">Go to last page</span>
|
||||||
<ChevronsRight />
|
<CaretDoubleRight />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -705,10 +705,10 @@ export function DataTable({
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="sm" className="cursor-pointer">
|
<Button variant="outline" size="sm" className="cursor-pointer">
|
||||||
<Columns2 />
|
<Columns />
|
||||||
<span className="hidden lg:inline">Customize Columns</span>
|
<span className="hidden lg:inline">Customize Columns</span>
|
||||||
<span className="lg:hidden">Columns</span>
|
<span className="lg:hidden">Columns</span>
|
||||||
<ChevronDown />
|
<CaretDown />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
@@ -838,7 +838,7 @@ export function DataTable({
|
|||||||
disabled={!table.getCanPreviousPage()}
|
disabled={!table.getCanPreviousPage()}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Go to first page</span>
|
<span className="sr-only">Go to first page</span>
|
||||||
<ChevronsLeft />
|
<CaretDoubleLeft />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -848,7 +848,7 @@ export function DataTable({
|
|||||||
disabled={!table.getCanPreviousPage()}
|
disabled={!table.getCanPreviousPage()}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Go to previous page</span>
|
<span className="sr-only">Go to previous page</span>
|
||||||
<ChevronLeft />
|
<CaretLeft />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -858,7 +858,7 @@ export function DataTable({
|
|||||||
disabled={!table.getCanNextPage()}
|
disabled={!table.getCanNextPage()}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Go to next page</span>
|
<span className="sr-only">Go to next page</span>
|
||||||
<ChevronRight />
|
<CaretRight />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -868,7 +868,7 @@ export function DataTable({
|
|||||||
disabled={!table.getCanNextPage()}
|
disabled={!table.getCanNextPage()}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Go to last page</span>
|
<span className="sr-only">Go to last page</span>
|
||||||
<ChevronsRight />
|
<CaretDoubleRight />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -992,7 +992,7 @@ function TableCellViewer({ item }: { item: z.infer<typeof schema> }) {
|
|||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<div className="flex gap-2 leading-none font-medium">
|
<div className="flex gap-2 leading-none font-medium">
|
||||||
Trending up by 5.2% this month{" "}
|
Trending up by 5.2% this month{" "}
|
||||||
<TrendingUp className="size-4" />
|
<TrendUp className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
Showing total visitors for the last 6 months. This is just
|
Showing total visitors for the last 6 months. This is just
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Phone, Envelope, CalendarCheck } from '@/lib/icons';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { CUSTOMER_STAGE_LABELS, CUSTOMER_TYPE_LABELS, type Customer } from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
|
function isOverdue(date: string) {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
return new Date(date) < today;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FollowUpWidget({ customers }: { customers: Customer[] }) {
|
||||||
|
const empty = customers.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden flex flex-col lg:h-full">
|
||||||
|
<CardHeader className="shrink-0">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<CalendarCheck className={`size-4 ${empty ? "text-muted-foreground" : "text-amber-500"}`} />
|
||||||
|
Bugünkü Takipler
|
||||||
|
{!empty && (
|
||||||
|
<Badge variant="secondary" className="ml-auto text-xs">{customers.length}</Badge>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-0 pb-3 lg:flex-1 lg:min-h-0 lg:overflow-y-auto">
|
||||||
|
{empty ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-6 px-4">
|
||||||
|
Bekleyen takip yok
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{customers.map((c) => {
|
||||||
|
const overdue = c.nextFollowUpDate ? isOverdue(c.nextFollowUpDate) : false;
|
||||||
|
return (
|
||||||
|
<li key={c.$id} className="flex items-center gap-3 px-6 py-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{c.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{CUSTOMER_TYPE_LABELS[c.type] ?? c.type}
|
||||||
|
{c.stage ? ` · ${CUSTOMER_STAGE_LABELS[c.stage] ?? c.stage}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
{overdue && (
|
||||||
|
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300">
|
||||||
|
Gecikti
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{c.phone && (
|
||||||
|
<a
|
||||||
|
href={`tel:${c.phone}`}
|
||||||
|
title={c.phone}
|
||||||
|
className="size-7 rounded-md bg-muted flex items-center justify-center hover:bg-primary hover:text-primary-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Phone className="size-3.5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{c.email && (
|
||||||
|
<a
|
||||||
|
href={`mailto:${c.email}`}
|
||||||
|
title={c.email}
|
||||||
|
className="size-7 rounded-md bg-muted flex items-center justify-center hover:bg-primary hover:text-primary-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Envelope className="size-3.5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Calendar, FilePlus, Receipt, UserPlus } from "lucide-react";
|
import { Calendar, FilePlus, Receipt, UserPlus } from '@/lib/icons';
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { tr } from "date-fns/locale";
|
||||||
|
import { ChatCircle, FileText, Eye, Phone, Note } from '@/lib/icons';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import type { Activity } from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
|
const typeConfig: Record<string, { label: string; icon: React.ElementType; variant: "default" | "secondary" | "outline" }> = {
|
||||||
|
gorusme: { label: "Görüşme", icon: ChatCircle, variant: "default" },
|
||||||
|
teklif: { label: "Teklif", icon: FileText, variant: "secondary" },
|
||||||
|
ziyaret: { label: "Ziyaret", icon: Eye, variant: "outline" },
|
||||||
|
arama: { label: "Arama", icon: Phone, variant: "outline" },
|
||||||
|
not: { label: "Not", icon: Note, variant: "outline" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RecentActivities({ activities }: { activities: Activity[] }) {
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden flex flex-col lg:h-full">
|
||||||
|
<CardHeader className="shrink-0">
|
||||||
|
<CardTitle className="text-base">Son Aktiviteler</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
{/* Mobil: içerik sayfayı scroll ettirir. Desktop: kart içinde kalır. */}
|
||||||
|
<CardContent className="px-4 pb-4 lg:flex-1 lg:min-h-0 lg:overflow-y-auto">
|
||||||
|
{activities.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-6 text-center">
|
||||||
|
Henüz aktivite yok
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{activities.map((a) => {
|
||||||
|
const cfg = typeConfig[a.type] ?? typeConfig.not;
|
||||||
|
const Icon = cfg.icon;
|
||||||
|
return (
|
||||||
|
<li key={a.$id} className="flex items-start gap-3 py-3 first:pt-0 last:pb-0">
|
||||||
|
<span className="mt-0.5 rounded-md bg-muted p-1.5 shrink-0">
|
||||||
|
<Icon className="size-3.5 text-muted-foreground" />
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="text-sm font-medium truncate">{a.title}</p>
|
||||||
|
<Badge variant={cfg.variant} className="text-xs px-1.5 py-0 shrink-0">
|
||||||
|
{cfg.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{a.description && (
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">
|
||||||
|
{a.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{formatDistanceToNow(new Date(a.$createdAt), { addSuffix: true, locale: tr })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { tr } from "date-fns/locale";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import type { Property } from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
|
const statusDot: Record<string, { color: string; label: string }> = {
|
||||||
|
aktif: { color: "bg-emerald-500", label: "Aktif" },
|
||||||
|
pasif: { color: "bg-zinc-400", label: "Pasif" },
|
||||||
|
satildi: { color: "bg-blue-500", label: "Satıldı" },
|
||||||
|
kiralandit: { color: "bg-orange-400", label: "Kiralandı" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const listingLabel: Record<string, string> = { satilik: "Satılık", kiralik: "Kiralık" };
|
||||||
|
const typeLabel: Record<string, string> = {
|
||||||
|
daire: "Daire", villa: "Villa", arsa: "Arsa",
|
||||||
|
dukkan: "Dükkan", ofis: "Ofis", depo: "Depo",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatPrice(price: number, currency = "TRY") {
|
||||||
|
return new Intl.NumberFormat("tr-TR", {
|
||||||
|
style: "currency", currency, maximumFractionDigits: 0,
|
||||||
|
}).format(price);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentProperties({ properties }: { properties: Property[] }) {
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden flex flex-col lg:h-full">
|
||||||
|
<CardHeader className="shrink-0">
|
||||||
|
<CardTitle className="text-base">Son Eklenen İlanlar</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-0 pb-4 lg:flex-1 lg:min-h-0 lg:overflow-y-auto">
|
||||||
|
{properties.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-6 text-center px-4">
|
||||||
|
Henüz ilan yok
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{properties.map((p) => {
|
||||||
|
const dot = statusDot[p.status] ?? { color: "bg-zinc-400", label: p.status };
|
||||||
|
return (
|
||||||
|
<li key={p.$id}>
|
||||||
|
<Link
|
||||||
|
href={`/properties/${p.$id}`}
|
||||||
|
className="flex items-center gap-3 px-6 py-3 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
title={dot.label}
|
||||||
|
className={`size-2 rounded-full shrink-0 ${dot.color}`}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium truncate">{p.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||||
|
{typeLabel[p.propertyType] ?? p.propertyType} · {listingLabel[p.listingType] ?? p.listingType}
|
||||||
|
{p.city ? ` · ${p.city}` : ""}
|
||||||
|
{p.roomCount ? ` · ${p.roomCount}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-0.5 shrink-0">
|
||||||
|
<p className="text-sm font-semibold tabular-nums">
|
||||||
|
{formatPrice(p.price, p.currency)}
|
||||||
|
</p>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatDistanceToNow(new Date(p.$createdAt), { addSuffix: true, locale: tr })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,102 +1,58 @@
|
|||||||
import { TrendingDown, TrendingUp } from "lucide-react"
|
import { Buildings, Users, Lightning, TrendUp } from '@/lib/icons';
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import type { DashboardStats } from "@/lib/appwrite/dashboard-queries";
|
||||||
import {
|
|
||||||
Card,
|
export function SectionCards({ stats }: { stats: DashboardStats }) {
|
||||||
CardAction,
|
const items = [
|
||||||
CardDescription,
|
{
|
||||||
CardFooter,
|
label: "Aktif İlan",
|
||||||
CardHeader,
|
value: stats.aktifIlanlar,
|
||||||
CardTitle,
|
icon: Buildings,
|
||||||
} from "@/components/ui/card"
|
sub: [
|
||||||
|
`${stats.satilikAktif} sat`,
|
||||||
|
`${stats.kiralikAktif} kir`,
|
||||||
|
...(stats.rezerveIlanlar > 0 ? [`${stats.rezerveIlanlar} rsv`] : []),
|
||||||
|
].join(" · "),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Müşteri",
|
||||||
|
value: stats.toplamMusteri,
|
||||||
|
icon: Users,
|
||||||
|
sub: `${stats.aliciMusteri} alıcı · ${stats.kiraciMusteri} kiracı · ${stats.yatirimciMusteri} yat`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Bekleyen Eşleşme",
|
||||||
|
value: stats.bekleyenEslesmeler,
|
||||||
|
icon: Lightning,
|
||||||
|
sub: "iletilmemiş bildirim",
|
||||||
|
accent: stats.bekleyenEslesmeler > 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Bu Ay Eklenen",
|
||||||
|
value: stats.buAyIlanlar,
|
||||||
|
icon: TrendUp,
|
||||||
|
sub: "yeni ilan",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export function SectionCards() {
|
|
||||||
return (
|
return (
|
||||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-px rounded-xl border bg-border overflow-hidden">
|
||||||
<Card className="@container/card">
|
{items.map((item) => (
|
||||||
<CardHeader>
|
<div key={item.label} className="bg-card px-4 py-3.5 flex items-center gap-3">
|
||||||
<CardDescription>Total Revenue</CardDescription>
|
<div className={`size-9 rounded-lg flex items-center justify-center shrink-0 ${
|
||||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
item.accent
|
||||||
$1,250.00
|
? "bg-amber-100 dark:bg-amber-950/40"
|
||||||
</CardTitle>
|
: "bg-muted"
|
||||||
<CardAction>
|
}`}>
|
||||||
<Badge variant="outline">
|
<item.icon className={`size-4 ${item.accent ? "text-amber-600 dark:text-amber-400" : "text-foreground/60"}`} />
|
||||||
<TrendingUp />
|
|
||||||
+12.5%
|
|
||||||
</Badge>
|
|
||||||
</CardAction>
|
|
||||||
</CardHeader>
|
|
||||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
|
||||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
|
||||||
Trending up this month <TrendingUp className="size-4" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground">
|
<div className="min-w-0">
|
||||||
Visitors for the last 6 months
|
<p className="text-2xl font-bold tabular-nums leading-none">{item.value}</p>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mt-1 leading-none">{item.label}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground/60 mt-0.5 truncate">{item.sub}</p>
|
||||||
</div>
|
</div>
|
||||||
</CardFooter>
|
</div>
|
||||||
</Card>
|
))}
|
||||||
<Card className="@container/card">
|
|
||||||
<CardHeader>
|
|
||||||
<CardDescription>New Customers</CardDescription>
|
|
||||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
|
||||||
1,234
|
|
||||||
</CardTitle>
|
|
||||||
<CardAction>
|
|
||||||
<Badge variant="outline">
|
|
||||||
<TrendingDown />
|
|
||||||
-20%
|
|
||||||
</Badge>
|
|
||||||
</CardAction>
|
|
||||||
</CardHeader>
|
|
||||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
|
||||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
|
||||||
Down 20% this period <TrendingDown className="size-4" />
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
Acquisition needs attention
|
|
||||||
</div>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
<Card className="@container/card">
|
|
||||||
<CardHeader>
|
|
||||||
<CardDescription>Active Accounts</CardDescription>
|
|
||||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
|
||||||
45,678
|
|
||||||
</CardTitle>
|
|
||||||
<CardAction>
|
|
||||||
<Badge variant="outline">
|
|
||||||
<TrendingUp />
|
|
||||||
+12.5%
|
|
||||||
</Badge>
|
|
||||||
</CardAction>
|
|
||||||
</CardHeader>
|
|
||||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
|
||||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
|
||||||
Strong user retention <TrendingUp className="size-4" />
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground">Engagement exceed targets</div>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
<Card className="@container/card">
|
|
||||||
<CardHeader>
|
|
||||||
<CardDescription>Growth Rate</CardDescription>
|
|
||||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
|
||||||
4.5%
|
|
||||||
</CardTitle>
|
|
||||||
<CardAction>
|
|
||||||
<Badge variant="outline">
|
|
||||||
<TrendingUp />
|
|
||||||
+4.5%
|
|
||||||
</Badge>
|
|
||||||
</CardAction>
|
|
||||||
</CardHeader>
|
|
||||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
|
||||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
|
||||||
Steady performance increase <TrendingUp className="size-4" />
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground">Meets growth projections</div>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,100 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Buildings, UserPlus, GitMerge, Plus } from '@/lib/icons';
|
||||||
|
|
||||||
import { getActiveContext } from "@/lib/appwrite/active-context";
|
import { getActiveContext } from "@/lib/appwrite/active-context";
|
||||||
|
import { getDashboardStats } from "@/lib/appwrite/dashboard-queries";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { SectionCards } from "./components/section-cards";
|
||||||
|
import { ChartAreaInteractive } from "./components/chart-area-interactive";
|
||||||
|
import { RecentActivities } from "./components/recent-activities";
|
||||||
|
import { RecentProperties } from "./components/recent-properties";
|
||||||
|
import { FollowUpWidget } from "./components/follow-up-widget";
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const ctx = await getActiveContext();
|
const ctx = await getActiveContext();
|
||||||
if (!ctx) redirect("/onboarding");
|
if (!ctx) redirect("/onboarding");
|
||||||
|
|
||||||
|
const stats = await getDashboardStats(ctx.tenantId);
|
||||||
|
|
||||||
const firstName = ctx.user.name?.split(" ")[0] ?? "";
|
const firstName = ctx.user.name?.split(" ")[0] ?? "";
|
||||||
const officeName = ctx.settings?.officeName ?? "Çalışma alanı";
|
const officeName = ctx.settings?.officeName ?? "Çalışma alanı";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-6 px-6 pt-0">
|
// overflow-x-hidden: Recharts SVG ve diğer absolute-positioned elementlerin
|
||||||
<div className="flex flex-col gap-1">
|
// yatay taşmasını keser; tooltip'ler kart içinde render olduğu için etkilenmez.
|
||||||
<p className="text-muted-foreground text-sm">{officeName}</p>
|
<div className="flex-1 space-y-4 px-4 sm:px-6 pt-0 overflow-x-hidden">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">
|
|
||||||
{firstName ? `Hoş geldiniz, ${firstName}` : "Genel Bakış"}
|
{/* Başlık */}
|
||||||
</h1>
|
<div className="flex items-center justify-between gap-3">
|
||||||
<p className="text-muted-foreground text-sm">
|
<div className="min-w-0">
|
||||||
Portföyünüzü ve müşteri aktivitelerini buradan takip edin.
|
<p className="text-xs text-muted-foreground truncate">{officeName}</p>
|
||||||
</p>
|
<h1 className="text-lg font-semibold tracking-tight truncate">
|
||||||
|
{firstName ? `Hoş geldiniz, ${firstName}` : "Genel Bakış"}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5 shrink-0">
|
||||||
|
<Button asChild size="sm" variant="outline" className="px-2 sm:px-3">
|
||||||
|
<Link href="/properties">
|
||||||
|
<Buildings className="size-3.5" />
|
||||||
|
<span className="hidden sm:inline">Yeni İlan</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="sm" variant="outline" className="px-2 sm:px-3">
|
||||||
|
<Link href="/customers">
|
||||||
|
<UserPlus className="size-3.5" />
|
||||||
|
<span className="hidden sm:inline">Müşteri</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="sm" variant="outline" className="px-2 sm:px-3">
|
||||||
|
<Link href="/presentations">
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
<span className="hidden sm:inline">Sunum</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="sm" variant="outline" className="px-2 sm:px-3">
|
||||||
|
<Link href="/customers/matches">
|
||||||
|
<GitMerge className="size-3.5" />
|
||||||
|
<span className="hidden sm:inline">Eşleşmeler</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
{/* İstatistik şeridi */}
|
||||||
<div className="bg-card rounded-xl border p-6">
|
<div data-tour="dashboard-stats">
|
||||||
<p className="text-muted-foreground text-sm">Aktif İlanlar</p>
|
<SectionCards stats={stats} />
|
||||||
<p className="mt-2 text-3xl font-bold">—</p>
|
</div>
|
||||||
|
|
||||||
|
{/* Grafik + Son Aktiviteler
|
||||||
|
Mobil: tek kolon, alt alta.
|
||||||
|
Desktop (lg): 3+2 kolon, eşit yükseklik (items-stretch). */}
|
||||||
|
<div className="grid gap-4 lg:grid-cols-5 lg:items-stretch">
|
||||||
|
<div className="min-w-0 lg:col-span-3">
|
||||||
|
<ChartAreaInteractive
|
||||||
|
ilanTrend={stats.aylikTrend}
|
||||||
|
musteriTrend={stats.aylikMusteriTrend}
|
||||||
|
aktiviteTrend={stats.aylikAktiviteTrend}
|
||||||
|
portfoyDagilim={stats.portfoyDagilim}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-card rounded-xl border p-6">
|
<div data-tour="dashboard-activity" className="min-w-0 lg:col-span-2">
|
||||||
<p className="text-muted-foreground text-sm">Müşteriler</p>
|
<RecentActivities activities={stats.sonAktiviteler} />
|
||||||
<p className="mt-2 text-3xl font-bold">—</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-card rounded-xl border p-6">
|
|
||||||
<p className="text-muted-foreground text-sm">Bekleyen Eşleşmeler</p>
|
|
||||||
<p className="mt-2 text-3xl font-bold">—</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Son İlanlar + Bugünkü Takipler */}
|
||||||
|
<div className="grid gap-4 lg:grid-cols-5 lg:items-stretch">
|
||||||
|
<div className="min-w-0 lg:col-span-3">
|
||||||
|
<RecentProperties properties={stats.sonIlanlar} />
|
||||||
|
</div>
|
||||||
|
<div data-tour="dashboard-matches" className="min-w-0 lg:col-span-2">
|
||||||
|
<FollowUpWidget customers={stats.takipMusteri} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||||
|
import { createAdminClient } from "@/lib/appwrite/server";
|
||||||
|
import { DATABASE_ID, TABLES } from "@/lib/appwrite/schema";
|
||||||
|
import { listCustomers } from "@/lib/appwrite/customer-queries";
|
||||||
|
import { Query } from "node-appwrite";
|
||||||
|
import { FinanceClient } from "@/components/finance/finance-client";
|
||||||
|
import type { Deal } from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
|
export default async function FinancePage() {
|
||||||
|
const ctx = await requireTenant();
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
|
||||||
|
const dealQueries = [
|
||||||
|
Query.equal("tenantId", ctx.tenantId),
|
||||||
|
Query.orderDesc("$createdAt"),
|
||||||
|
Query.limit(200),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (ctx.role === "member") {
|
||||||
|
dealQueries.push(Query.equal("agentId", ctx.user.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const [dealsResult, customers] = await Promise.all([
|
||||||
|
tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.deals,
|
||||||
|
queries: dealQueries,
|
||||||
|
}),
|
||||||
|
listCustomers(ctx.tenantId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const deals = JSON.parse(JSON.stringify(dealsResult.rows)) as Deal[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 md:px-6 lg:px-8">
|
||||||
|
<FinanceClient
|
||||||
|
initialDeals={deals}
|
||||||
|
customers={customers}
|
||||||
|
role={ctx.role}
|
||||||
|
userId={ctx.user.id}
|
||||||
|
userName={ctx.user.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { Query } from "node-appwrite";
|
||||||
|
|
||||||
import { getActiveContext } from "@/lib/appwrite/active-context";
|
import { getActiveContext } from "@/lib/appwrite/active-context";
|
||||||
import { getLogoUrl } from "@/lib/appwrite/storage";
|
import { getLogoUrl } from "@/lib/appwrite/storage";
|
||||||
import { createSessionClient } from "@/lib/appwrite/server";
|
import { createAdminClient, createSessionClient } from "@/lib/appwrite/server";
|
||||||
|
import { DATABASE_ID, TABLES } from "@/lib/appwrite/schema";
|
||||||
import type { ThemePrefs } from "@/lib/appwrite/theme-prefs-actions";
|
import type { ThemePrefs } from "@/lib/appwrite/theme-prefs-actions";
|
||||||
import { DashboardShell } from "./dashboard-shell";
|
import { DashboardShell } from "./dashboard-shell";
|
||||||
|
|
||||||
@@ -14,6 +16,17 @@ export default async function DashboardLayout({
|
|||||||
const ctx = await getActiveContext();
|
const ctx = await getActiveContext();
|
||||||
if (!ctx) redirect("/onboarding");
|
if (!ctx) redirect("/onboarding");
|
||||||
|
|
||||||
|
let pendingMatchCount = 0;
|
||||||
|
try {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const res = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.propertyMatches,
|
||||||
|
queries: [Query.equal("tenantId", ctx.tenantId), Query.equal("notified", false), Query.limit(1)],
|
||||||
|
});
|
||||||
|
pendingMatchCount = res.total;
|
||||||
|
} catch { /* non-critical */ }
|
||||||
|
|
||||||
let themePrefs: ThemePrefs = {};
|
let themePrefs: ThemePrefs = {};
|
||||||
try {
|
try {
|
||||||
const { account } = await createSessionClient();
|
const { account } = await createSessionClient();
|
||||||
@@ -37,7 +50,7 @@ export default async function DashboardLayout({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardShell user={user} company={company} initialPrefs={themePrefs}>
|
<DashboardShell user={user} company={company} initialPrefs={themePrefs} pendingMatchCount={pendingMatchCount}>
|
||||||
{children}
|
{children}
|
||||||
</DashboardShell>
|
</DashboardShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Link from "next/link";
|
|
||||||
import { ArrowLeft } from "lucide-react";
|
|
||||||
import { Query } from "node-appwrite";
|
import { Query } from "node-appwrite";
|
||||||
|
|
||||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||||
@@ -10,18 +8,13 @@ import { listCustomers } from "@/lib/appwrite/customer-queries";
|
|||||||
import {
|
import {
|
||||||
DATABASE_ID,
|
DATABASE_ID,
|
||||||
TABLES,
|
TABLES,
|
||||||
PROPERTY_TYPE_LABELS,
|
|
||||||
LISTING_TYPE_LABELS,
|
|
||||||
PROPERTY_STATUS_LABELS,
|
|
||||||
ACTIVITY_TYPE_LABELS,
|
|
||||||
type Property,
|
type Property,
|
||||||
type PropertyMatch,
|
type PropertyMatch,
|
||||||
type Activity,
|
type Activity,
|
||||||
} from "@/lib/appwrite/schema";
|
} from "@/lib/appwrite/schema";
|
||||||
import { createAdminClient } from "@/lib/appwrite/server";
|
import { createAdminClient } from "@/lib/appwrite/server";
|
||||||
import { getPropertyImagePreviewUrl, parseImageIds } from "@/lib/appwrite/storage-utils";
|
import { parseImageIds } from "@/lib/appwrite/storage-utils";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { PropertyDetailClient } from "@/components/properties/property-detail-client";
|
||||||
import { PropertyMapView } from "@/components/map/property-map-view";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -71,216 +64,13 @@ export default async function PropertyDetailPage({ params }: Props) {
|
|||||||
const customerMap = Object.fromEntries(customers.map((c) => [c.$id, c.name]));
|
const customerMap = Object.fromEntries(customers.map((c) => [c.$id, c.name]));
|
||||||
const imageIds = parseImageIds(property.imageIds);
|
const imageIds = parseImageIds(property.imageIds);
|
||||||
|
|
||||||
const statusColor: Record<string, string> = {
|
|
||||||
aktif: "bg-green-100 text-green-700",
|
|
||||||
pasif: "bg-gray-100 text-gray-600",
|
|
||||||
satildi: "bg-orange-100 text-orange-700",
|
|
||||||
kiralandit: "bg-blue-100 text-blue-700",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6 max-w-5xl">
|
<PropertyDetailClient
|
||||||
{/* Header */}
|
property={property}
|
||||||
<div className="flex items-center gap-3">
|
matches={matches}
|
||||||
<Link
|
activities={activities}
|
||||||
href="/properties"
|
imageIds={imageIds}
|
||||||
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-sm"
|
customerMap={customerMap}
|
||||||
>
|
/>
|
||||||
<ArrowLeft className="size-4" />
|
|
||||||
İlanlar
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold">{property.title}</h1>
|
|
||||||
<p className="text-muted-foreground mt-1 text-sm">
|
|
||||||
{[property.neighborhood, property.district, property.city].filter(Boolean).join(", ")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center rounded-full px-3 py-1 text-sm font-medium ${statusColor[property.status] ?? "bg-gray-100 text-gray-600"}`}
|
|
||||||
>
|
|
||||||
{PROPERTY_STATUS_LABELS[property.status] ?? property.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Photo gallery */}
|
|
||||||
{imageIds.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className={`grid gap-2 ${
|
|
||||||
imageIds.length === 1
|
|
||||||
? "grid-cols-1"
|
|
||||||
: imageIds.length === 2
|
|
||||||
? "grid-cols-2"
|
|
||||||
: "grid-cols-3"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{imageIds.map((fileId, i) => (
|
|
||||||
<div
|
|
||||||
key={fileId}
|
|
||||||
className={`overflow-hidden rounded-lg border bg-muted ${i === 0 && imageIds.length > 2 ? "col-span-2 row-span-2" : ""}`}
|
|
||||||
>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={getPropertyImagePreviewUrl(fileId, 1200, 900)}
|
|
||||||
alt={`${property.title} fotoğraf ${i + 1}`}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
style={{ maxHeight: i === 0 && imageIds.length > 2 ? "480px" : "240px" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
|
||||||
{/* Price */}
|
|
||||||
<div className="md:col-span-2 space-y-6">
|
|
||||||
<div className="rounded-lg border p-4 space-y-4">
|
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<span className="text-3xl font-bold">
|
|
||||||
{property.price.toLocaleString("tr-TR")}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">{property.currency ?? "TRY"}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
|
||||||
<Detail label="Emlak tipi" value={PROPERTY_TYPE_LABELS[property.propertyType]} />
|
|
||||||
<Detail label="İlan türü" value={LISTING_TYPE_LABELS[property.listingType]} />
|
|
||||||
{property.roomCount && <Detail label="Oda sayısı" value={property.roomCount} />}
|
|
||||||
{property.netM2 && <Detail label="Net m²" value={`${property.netM2} m²`} />}
|
|
||||||
{property.grossM2 && <Detail label="Brüt m²" value={`${property.grossM2} m²`} />}
|
|
||||||
{property.floor != null && <Detail label="Kat" value={String(property.floor)} />}
|
|
||||||
{property.totalFloors != null && (
|
|
||||||
<Detail label="Top. kat" value={String(property.totalFloors)} />
|
|
||||||
)}
|
|
||||||
{property.buildingAge != null && (
|
|
||||||
<Detail label="Bina yaşı" value={`${property.buildingAge} yıl`} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{property.address && (
|
|
||||||
<div className="text-sm">
|
|
||||||
<span className="text-muted-foreground">Adres: </span>
|
|
||||||
{property.address}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{property.mapLat != null && property.mapLng != null && (
|
|
||||||
<a
|
|
||||||
href={`https://www.google.com/maps?q=${property.mapLat},${property.mapLng}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-xs text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
Google Maps'te aç ↗
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{property.description && (
|
|
||||||
<div className="rounded-lg border p-4">
|
|
||||||
<h2 className="mb-2 text-sm font-semibold">Açıklama</h2>
|
|
||||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
|
||||||
{property.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{property.mapLat != null && property.mapLng != null && (
|
|
||||||
<div className="rounded-lg border overflow-hidden">
|
|
||||||
<h2 className="px-4 pt-4 pb-2 text-sm font-semibold">Konum</h2>
|
|
||||||
<PropertyMapView
|
|
||||||
lat={property.mapLat}
|
|
||||||
lng={property.mapLng}
|
|
||||||
title={property.title}
|
|
||||||
className="h-64 rounded-none border-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Activities */}
|
|
||||||
{activities.length > 0 && (
|
|
||||||
<div className="rounded-lg border p-4">
|
|
||||||
<h2 className="mb-3 text-sm font-semibold">Aktiviteler</h2>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{activities.map((a) => (
|
|
||||||
<div key={a.$id} className="flex items-start gap-2 text-sm">
|
|
||||||
<span className="text-muted-foreground mt-0.5 shrink-0">
|
|
||||||
{ACTIVITY_TYPE_LABELS[a.type] ?? a.type}
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="font-medium truncate">{a.title}</p>
|
|
||||||
{a.description && (
|
|
||||||
<p className="text-muted-foreground text-xs truncate">{a.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-muted-foreground text-xs ml-auto shrink-0">
|
|
||||||
{new Date(a.$createdAt).toLocaleDateString("tr-TR")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Matches sidebar */}
|
|
||||||
<div>
|
|
||||||
<div className="rounded-lg border p-4">
|
|
||||||
<h2 className="mb-3 text-sm font-semibold">
|
|
||||||
İlgili Müşteriler
|
|
||||||
{matches.length > 0 && (
|
|
||||||
<span className="ml-1.5 text-muted-foreground font-normal">({matches.length})</span>
|
|
||||||
)}
|
|
||||||
</h2>
|
|
||||||
{matches.length === 0 ? (
|
|
||||||
<p className="text-muted-foreground text-xs">Eşleşme yok.</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{matches.map((m) => (
|
|
||||||
<div key={m.$id} className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-sm truncate">
|
|
||||||
{customerMap[m.customerId] ?? m.customerId}
|
|
||||||
</span>
|
|
||||||
<ScoreBadge score={m.score} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Detail({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<span className="text-muted-foreground">{label}: </span>
|
|
||||||
<span className="font-medium">{value}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScoreBadge({ score }: { score?: number | null }) {
|
|
||||||
const s = score ?? 0;
|
|
||||||
const color =
|
|
||||||
s >= 80
|
|
||||||
? "bg-green-100 text-green-700"
|
|
||||||
: s >= 60
|
|
||||||
? "bg-blue-100 text-blue-700"
|
|
||||||
: s >= 40
|
|
||||||
? "bg-yellow-100 text-yellow-700"
|
|
||||||
: "bg-gray-100 text-gray-500";
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-semibold ${color}`}
|
|
||||||
>
|
|
||||||
{s}
|
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState, useEffect, useRef } from "react";
|
import { useActionState, useEffect, useRef } from "react";
|
||||||
import { Loader2, Save } from "lucide-react";
|
import { CircleNotch, FloppyDisk } from '@/lib/icons';
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -65,12 +65,12 @@ export function EmailForm({ currentEmail }: { currentEmail: string }) {
|
|||||||
<Button type="submit" disabled={isPending}>
|
<Button type="submit" disabled={isPending}>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<CircleNotch className="size-4 animate-spin" />
|
||||||
Güncelleniyor...
|
Güncelleniyor...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Save className="size-4" />
|
<FloppyDisk className="size-4" />
|
||||||
Email'i güncelle
|
Email'i güncelle
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState, useEffect } from "react";
|
import { useActionState, useEffect } from "react";
|
||||||
import { Loader2, Save } from "lucide-react";
|
import { CircleNotch, FloppyDisk } from '@/lib/icons';
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -39,12 +39,12 @@ export function NameForm({ currentName }: { currentName: string }) {
|
|||||||
<Button type="submit" disabled={isPending}>
|
<Button type="submit" disabled={isPending}>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<CircleNotch className="size-4 animate-spin" />
|
||||||
Kaydediliyor...
|
Kaydediliyor...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Save className="size-4" />
|
<FloppyDisk className="size-4" />
|
||||||
Kaydet
|
Kaydet
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState, useEffect, useRef } from "react";
|
import { useActionState, useEffect, useRef } from "react";
|
||||||
import { KeyRound, Loader2 } from "lucide-react";
|
import { Key, CircleNotch } from '@/lib/icons';
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -82,12 +82,12 @@ export function PasswordForm() {
|
|||||||
<Button type="submit" disabled={isPending}>
|
<Button type="submit" disabled={isPending}>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<CircleNotch className="size-4 animate-spin" />
|
||||||
Güncelleniyor...
|
Güncelleniyor...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<KeyRound className="size-4" />
|
<Key className="size-4" />
|
||||||
Şifreyi değiştir
|
Şifreyi değiştir
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ export default function AppearanceSettings() {
|
|||||||
|
|
||||||
<div className="flex space-x-2 mt-12">
|
<div className="flex space-x-2 mt-12">
|
||||||
<Button type="submit" className="cursor-pointer">
|
<Button type="submit" className="cursor-pointer">
|
||||||
Save Preferences
|
FloppyDisk Preferences
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" type="button" className="cursor-pointer">Cancel</Button>
|
<Button variant="outline" type="button" className="cursor-pointer">Cancel</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Crown, Zap } from "lucide-react";
|
import { Crown, Lightning } from '@/lib/icons';
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import type { PlanUsage } from "@/lib/appwrite/plan-limits";
|
import type { PlanUsage } from "@/lib/appwrite/plan-limits";
|
||||||
import type { TenantPlan } from "@/lib/appwrite/schema";
|
import type { TenantPlan } from "@/lib/appwrite/schema";
|
||||||
import { RESOURCE_LABELS } from "@/lib/appwrite/plan-limits";
|
import { RESOURCE_LABELS } from "@/lib/appwrite/plan-limits-shared";
|
||||||
|
|
||||||
const LIMIT_LABELS: Record<string, string> = {
|
const LIMIT_LABELS: Record<string, string> = {
|
||||||
properties: "İlan",
|
properties: "İlan",
|
||||||
@@ -36,7 +36,7 @@ export function CurrentPlanCard({
|
|||||||
variant={isPro ? "default" : "secondary"}
|
variant={isPro ? "default" : "secondary"}
|
||||||
className="gap-1"
|
className="gap-1"
|
||||||
>
|
>
|
||||||
{isPro ? <Crown className="h-3 w-3" /> : <Zap className="h-3 w-3" />}
|
{isPro ? <Crown className="h-3 w-3" /> : <Lightning className="h-3 w-3" />}
|
||||||
{isPro ? "Pro" : "Ücretsiz"}
|
{isPro ? "Pro" : "Ücretsiz"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Crown, Check, Loader2 } from "lucide-react";
|
import { Crown, Check, CircleNotch } from '@/lib/icons';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { CheckCircle2, XCircle } from "lucide-react";
|
import { CheckCircle, XCircle } from '@/lib/icons';
|
||||||
|
|
||||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||||
import { getEffectivePlan, getPlanUsage } from "@/lib/appwrite/plan-limits";
|
import { getEffectivePlan, getPlanUsage } from "@/lib/appwrite/plan-limits";
|
||||||
@@ -44,7 +44,7 @@ export default async function BillingPage({
|
|||||||
|
|
||||||
{upgraded && (
|
{upgraded && (
|
||||||
<div className="flex items-center gap-2 rounded-md border border-green-500/30 bg-green-500/10 px-4 py-3 text-sm text-green-700 dark:text-green-400">
|
<div className="flex items-center gap-2 rounded-md border border-green-500/30 bg-green-500/10 px-4 py-3 text-sm text-green-700 dark:text-green-400">
|
||||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
<CheckCircle className="h-4 w-4 shrink-0" />
|
||||||
Pro plana başarıyla geçtiniz. İyi kullanımlar!
|
Pro plana başarıyla geçtiniz. İyi kullanımlar!
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { Github, Slack, Twitter, Zap, Globe, Database, Apple, Chrome, Facebook, Instagram, Dribbble } from "lucide-react"
|
import { GithubLogo, SlackLogo, TwitterLogo, Lightning, Globe, Database, AppleLogo, Browser, FacebookLogo, InstagramLogo, DribbbleLogo } from '@/lib/icons'
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
export default function ConnectionSettings() {
|
export default function ConnectionSettings() {
|
||||||
// Controlled state for switches
|
// Controlled state for switches
|
||||||
@@ -37,9 +37,9 @@ export default function ConnectionSettings() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Apple className="h-8 w-8" />
|
<AppleLogo className="h-8 w-8" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">Apple</div>
|
<div className="font-medium">AppleLogo</div>
|
||||||
<div className="text-sm text-muted-foreground">Calendar and contacts</div>
|
<div className="text-sm text-muted-foreground">Calendar and contacts</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,7 +52,7 @@ export default function ConnectionSettings() {
|
|||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Chrome className="h-8 w-8" />
|
<Browser className="h-8 w-8" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">Google</div>
|
<div className="font-medium">Google</div>
|
||||||
<div className="text-sm text-muted-foreground">Calendar and contacts</div>
|
<div className="text-sm text-muted-foreground">Calendar and contacts</div>
|
||||||
@@ -67,9 +67,9 @@ export default function ConnectionSettings() {
|
|||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Github className="h-8 w-8" />
|
<GithubLogo className="h-8 w-8" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">Github</div>
|
<div className="font-medium">GithubLogo</div>
|
||||||
<div className="text-sm text-muted-foreground">Manage your Git repositories</div>
|
<div className="text-sm text-muted-foreground">Manage your Git repositories</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,9 +82,9 @@ export default function ConnectionSettings() {
|
|||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Slack className="h-8 w-8" />
|
<SlackLogo className="h-8 w-8" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">Slack</div>
|
<div className="font-medium">SlackLogo</div>
|
||||||
<div className="text-sm text-muted-foreground">Communication</div>
|
<div className="text-sm text-muted-foreground">Communication</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,13 +109,13 @@ export default function ConnectionSettings() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Facebook className="h-8 w-8" />
|
<FacebookLogo className="h-8 w-8" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
Facebook
|
FacebookLogo
|
||||||
<Badge variant="outline" className="ml-2">Not Connected</Badge>
|
<Badge variant="outline" className="ml-2">Not Connected</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">Share updates on Facebook</div>
|
<div className="text-sm text-muted-foreground">Share updates on FacebookLogo</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="icon" className="cursor-pointer">
|
<Button variant="outline" size="icon" className="cursor-pointer">
|
||||||
@@ -125,13 +125,13 @@ export default function ConnectionSettings() {
|
|||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Twitter className="h-8 w-8" />
|
<TwitterLogo className="h-8 w-8" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
Twitter
|
TwitterLogo
|
||||||
<Badge variant="secondary" className="ml-2">connected</Badge>
|
<Badge variant="secondary" className="ml-2">connected</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">Share updates on Twitter</div>
|
<div className="text-sm text-muted-foreground">Share updates on TwitterLogo</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="icon" className="cursor-pointer text-destructive">
|
<Button variant="outline" size="icon" className="cursor-pointer text-destructive">
|
||||||
@@ -141,13 +141,13 @@ export default function ConnectionSettings() {
|
|||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Instagram className="h-8 w-8" />
|
<InstagramLogo className="h-8 w-8" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
Instagram
|
InstagramLogo
|
||||||
<Badge variant="secondary" className="ml-2">connected</Badge>
|
<Badge variant="secondary" className="ml-2">connected</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">Stay connected at Instagram</div>
|
<div className="text-sm text-muted-foreground">Stay connected at InstagramLogo</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="icon" className="cursor-pointer text-destructive">
|
<Button variant="outline" size="icon" className="cursor-pointer text-destructive">
|
||||||
@@ -157,13 +157,13 @@ export default function ConnectionSettings() {
|
|||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Dribbble className="h-8 w-8" />
|
<DribbbleLogo className="h-8 w-8" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
Dribbble
|
DribbbleLogo
|
||||||
<Badge variant="outline" className="ml-2">Not Connected</Badge>
|
<Badge variant="outline" className="ml-2">Not Connected</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">Stay connected at Dribbble</div>
|
<div className="text-sm text-muted-foreground">Stay connected at DribbbleLogo</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="icon" className="cursor-pointer">
|
<Button variant="outline" size="icon" className="cursor-pointer">
|
||||||
@@ -186,7 +186,7 @@ export default function ConnectionSettings() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Zap className="h-8 w-8" />
|
<Lightning className="h-8 w-8" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">Zapier</div>
|
<div className="font-medium">Zapier</div>
|
||||||
<div className="text-sm text-muted-foreground">Automate workflows with Zapier</div>
|
<div className="text-sm text-muted-foreground">Automate workflows with Zapier</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState, useEffect, useRef, useState } from "react";
|
import { useActionState, useEffect, useRef, useState } from "react";
|
||||||
import { Check, Copy, Loader2, UserPlus } from "lucide-react";
|
import { Check, Copy, CircleNotch, UserPlus } from '@/lib/icons';
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -80,7 +80,7 @@ export function InviteForm() {
|
|||||||
<Button type="submit" disabled={isPending} className="w-full md:w-auto">
|
<Button type="submit" disabled={isPending} className="w-full md:w-auto">
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<CircleNotch className="size-4 animate-spin" />
|
||||||
Gönderiliyor...
|
Gönderiliyor...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { DoorOpen, Loader2, Trash2 } from "lucide-react";
|
import { DoorOpen, CircleNotch, Trash } from '@/lib/icons';
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -179,7 +179,7 @@ export function MembersTable({
|
|||||||
onClick={() => setLeaving(true)}
|
onClick={() => setLeaving(true)}
|
||||||
>
|
>
|
||||||
{busy === "leave" ? (
|
{busy === "leave" ? (
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
<CircleNotch className="size-3.5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<DoorOpen className="size-3.5" />
|
<DoorOpen className="size-3.5" />
|
||||||
)}
|
)}
|
||||||
@@ -195,9 +195,9 @@ export function MembersTable({
|
|||||||
onClick={() => setRemoving(m)}
|
onClick={() => setRemoving(m)}
|
||||||
>
|
>
|
||||||
{busy === m.id ? (
|
{busy === m.id ? (
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
<CircleNotch className="size-3.5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Trash2 className="size-3.5" />
|
<Trash className="size-3.5" />
|
||||||
)}
|
)}
|
||||||
Çıkar
|
Çıkar
|
||||||
</Button>
|
</Button>
|
||||||
@@ -240,7 +240,7 @@ export function MembersTable({
|
|||||||
onClick={handleRemove}
|
onClick={handleRemove}
|
||||||
disabled={busy !== null}
|
disabled={busy !== null}
|
||||||
>
|
>
|
||||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
|
{busy ? <CircleNotch className="size-4 animate-spin" /> : <Trash className="size-4" />}
|
||||||
Çıkar
|
Çıkar
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -269,7 +269,7 @@ export function MembersTable({
|
|||||||
onClick={handleLeave}
|
onClick={handleLeave}
|
||||||
disabled={busy !== null}
|
disabled={busy !== null}
|
||||||
>
|
>
|
||||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <DoorOpen className="size-4" />}
|
{busy ? <CircleNotch className="size-4 animate-spin" /> : <DoorOpen className="size-4" />}
|
||||||
Ayrıl
|
Ayrıl
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTransition, useState } from "react";
|
import { useTransition, useState } from "react";
|
||||||
import { Check, Copy, Loader2, X } from "lucide-react";
|
import { Check, Copy, CircleNotch, X } from '@/lib/icons';
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -119,7 +119,7 @@ export function PendingInvitesTable({
|
|||||||
onClick={() => cancel(inv.id)}
|
onClick={() => cancel(inv.id)}
|
||||||
>
|
>
|
||||||
{busy === inv.id ? (
|
{busy === inv.id ? (
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
<CircleNotch className="size-3.5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<X className="size-3.5" />
|
<X className="size-3.5" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { Checkbox } from "@/components/ui/checkbox"
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { Bell, Mail, MessageSquare } from "lucide-react"
|
import { Bell, Envelope, ChatCircle } from '@/lib/icons'
|
||||||
|
|
||||||
const notificationsFormSchema = z.object({
|
const notificationsFormSchema = z.object({
|
||||||
emailSecurity: z.boolean(),
|
emailSecurity: z.boolean(),
|
||||||
@@ -595,7 +595,7 @@ export default function NotificationSettings() {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex items-center justify-between">
|
<FormItem className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Mail className="h-5 w-5 text-muted-foreground" />
|
<Envelope className="h-5 w-5 text-muted-foreground" />
|
||||||
<div>
|
<div>
|
||||||
<FormLabel className="font-medium mb-1">Email</FormLabel>
|
<FormLabel className="font-medium mb-1">Email</FormLabel>
|
||||||
<div className="text-sm text-muted-foreground">Receive notifications via email</div>
|
<div className="text-sm text-muted-foreground">Receive notifications via email</div>
|
||||||
@@ -639,7 +639,7 @@ export default function NotificationSettings() {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex items-center justify-between">
|
<FormItem className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<MessageSquare className="h-5 w-5 text-muted-foreground" />
|
<ChatCircle className="h-5 w-5 text-muted-foreground" />
|
||||||
<div>
|
<div>
|
||||||
<FormLabel className="font-medium mb-1">SMS</FormLabel>
|
<FormLabel className="font-medium mb-1">SMS</FormLabel>
|
||||||
<div className="text-sm text-muted-foreground">Receive notifications via SMS</div>
|
<div className="text-sm text-muted-foreground">Receive notifications via SMS</div>
|
||||||
@@ -659,7 +659,7 @@ export default function NotificationSettings() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<Button type="submit" className="cursor-pointer">Save Preferences</Button>
|
<Button type="submit" className="cursor-pointer">FloppyDisk Preferences</Button>
|
||||||
<Button variant="outline" type="reset" className="cursor-pointer">Cancel</Button>
|
<Button variant="outline" type="reset" className="cursor-pointer">Cancel</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { Upload } from "lucide-react"
|
import { Upload } from '@/lib/icons'
|
||||||
import { useRef, useState } from "react"
|
import { useRef, useState } from "react"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { Logo } from "@/components/logo"
|
import { Logo } from "@/components/logo"
|
||||||
@@ -95,7 +95,7 @@ export default function UserSettingsPage() {
|
|||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Profile Settings</CardTitle>
|
<CardTitle>Profile GearSix</CardTitle>
|
||||||
<CardDescription>Update your personal information and preferences</CardDescription>
|
<CardDescription>Update your personal information and preferences</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
@@ -346,7 +346,7 @@ export default function UserSettingsPage() {
|
|||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex justify-start gap-3">
|
<div className="flex justify-start gap-3">
|
||||||
<Button type="submit" className="cursor-pointer">
|
<Button type="submit" className="cursor-pointer">
|
||||||
Save Changes
|
FloppyDisk Changes
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" type="button" className="cursor-pointer">
|
<Button variant="outline" type="button" className="cursor-pointer">
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState, useEffect, useRef, useState, useTransition } from "react";
|
import { useActionState, useEffect, useRef, useState, useTransition } from "react";
|
||||||
import { Building2, ImagePlus, Loader2, Trash2, Upload } from "lucide-react";
|
import { Buildings, ImageSquare, CircleNotch, Trash, Upload } from '@/lib/icons';
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -104,7 +104,7 @@ export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Building2 className="size-4" />
|
<Buildings className="size-4" />
|
||||||
Logo
|
Logo
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -125,7 +125,7 @@ export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-muted-foreground flex flex-col items-center gap-2 p-4 text-center text-xs">
|
<div className="text-muted-foreground flex flex-col items-center gap-2 p-4 text-center text-xs">
|
||||||
<Building2 className="size-8 opacity-40" />
|
<Buildings className="size-8 opacity-40" />
|
||||||
<span>Henüz logo yok</span>
|
<span>Henüz logo yok</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -155,7 +155,7 @@ export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
|
|||||||
disabled={!canEdit || busy}
|
disabled={!canEdit || busy}
|
||||||
onChange={(e) => handleFile(e.target.files?.[0] ?? null)}
|
onChange={(e) => handleFile(e.target.files?.[0] ?? null)}
|
||||||
/>
|
/>
|
||||||
<ImagePlus className="text-muted-foreground size-6" />
|
<ImageSquare className="text-muted-foreground size-6" />
|
||||||
<div className="text-sm font-medium">
|
<div className="text-sm font-medium">
|
||||||
{selectedName ?? "Logo yüklemek için tıkla veya sürükle bırak"}
|
{selectedName ?? "Logo yüklemek için tıkla veya sürükle bırak"}
|
||||||
</div>
|
</div>
|
||||||
@@ -169,7 +169,7 @@ export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
|
|||||||
<Button type="submit" disabled={submitDisabled}>
|
<Button type="submit" disabled={submitDisabled}>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<CircleNotch className="size-4 animate-spin" />
|
||||||
Yükleniyor...
|
Yükleniyor...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -189,9 +189,9 @@ export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
|
|||||||
className="text-destructive hover:text-destructive"
|
className="text-destructive hover:text-destructive"
|
||||||
>
|
>
|
||||||
{removing ? (
|
{removing ? (
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<CircleNotch className="size-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Trash2 className="size-4" />
|
<Trash className="size-4" />
|
||||||
)}
|
)}
|
||||||
Kaldır
|
Kaldır
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState, useEffect } from "react";
|
import { useActionState, useEffect } from "react";
|
||||||
import { Building2, Loader2, Save } from "lucide-react";
|
import { Buildings, CircleNotch, FloppyDisk } from '@/lib/icons';
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -43,7 +43,7 @@ export function WorkspaceSettingsForm({
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Building2 className="size-4" />
|
<Buildings className="size-4" />
|
||||||
Ofis Bilgileri
|
Ofis Bilgileri
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Müşterilere ve sunumlarda gösterilecek ofis bilgileri.</CardDescription>
|
<CardDescription>Müşterilere ve sunumlarda gösterilecek ofis bilgileri.</CardDescription>
|
||||||
@@ -117,12 +117,12 @@ export function WorkspaceSettingsForm({
|
|||||||
<Button type="submit" disabled={isPending}>
|
<Button type="submit" disabled={isPending}>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<CircleNotch className="size-4 animate-spin" />
|
||||||
Kaydediliyor...
|
Kaydediliyor...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Save className="size-4" />
|
<FloppyDisk className="size-4" />
|
||||||
Kaydet
|
Kaydet
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSidebar } from "@/components/ui/sidebar";
|
||||||
|
|
||||||
|
export function SidebarOverlay() {
|
||||||
|
const { open, isMobile, toggleSidebar } = useSidebar();
|
||||||
|
if (isMobile || !open) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[9] bg-black/20"
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { ID, Permission, Role } from "node-appwrite";
|
||||||
|
import { InputFile } from "node-appwrite/file";
|
||||||
|
|
||||||
|
import { BUCKETS } from "@/lib/appwrite/schema";
|
||||||
|
import { createAdminClient } from "@/lib/appwrite/server";
|
||||||
|
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||||
|
|
||||||
|
const MAX_BYTES = 20 * 1024 * 1024;
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ ok: false, error: "Oturum geçersiz." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let formData: FormData;
|
||||||
|
try {
|
||||||
|
formData = await request.formData();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ ok: false, error: "Geçersiz istek." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = formData.get("file");
|
||||||
|
if (!(file instanceof File) || file.size === 0) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Dosya seçin." }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Sadece görsel dosyaları desteklenir." }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (file.size > MAX_BYTES) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Dosya 20 MB'dan büyük olamaz." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storage } = createAdminClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const inputFile = InputFile.fromBuffer(buffer, file.name);
|
||||||
|
|
||||||
|
const created = await storage.createFile(
|
||||||
|
BUCKETS.propertyImages,
|
||||||
|
ID.unique(),
|
||||||
|
inputFile,
|
||||||
|
[
|
||||||
|
Permission.read(Role.any()),
|
||||||
|
Permission.update(Role.team(ctx.tenantId)),
|
||||||
|
Permission.delete(Role.team(ctx.tenantId, "owner")),
|
||||||
|
Permission.delete(Role.team(ctx.tenantId, "admin")),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, fileId: created.$id });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("Property image upload error:", e);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: process.env.NODE_ENV === "development" ? msg : "Fotoğraf yüklenemedi." },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { CheckCircle2, Loader2 } from "lucide-react";
|
import { CheckCircle, CircleNotch } from '@/lib/icons';
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { acceptInviteAction } from "@/lib/appwrite/team-actions";
|
import { acceptInviteAction } from "@/lib/appwrite/team-actions";
|
||||||
@@ -30,12 +30,12 @@ export function AcceptInviteButton({ code }: { code: string }) {
|
|||||||
<Button onClick={handleAccept} disabled={isPending} className="w-full">
|
<Button onClick={handleAccept} disabled={isPending} className="w-full">
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<CircleNotch className="size-4 animate-spin" />
|
||||||
Katılınıyor...
|
Katılınıyor...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CheckCircle2 className="size-4" />
|
<CheckCircle className="size-4" />
|
||||||
Daveti kabul et
|
Daveti kabul et
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -170,3 +170,11 @@ html {
|
|||||||
.animate-logo-scroll:hover {
|
.animate-logo-scroll:hover {
|
||||||
animation-play-state: paused;
|
animation-play-state: paused;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Logo } from "@/components/logo";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Kullanım Şartları — Emlak CRM",
|
||||||
|
description:
|
||||||
|
"Kovak Yazılım ve Medya LTD. ŞTİ. tarafından sunulan Emlak CRM hizmetine ait kullanım şartları ve koşulları.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function KullanimSartlariPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="border-b">
|
||||||
|
<div className="max-w-4xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
|
<Link href="/" className="flex items-center gap-2">
|
||||||
|
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
|
||||||
|
<Logo size={18} />
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold">Emlak CRM</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/kvkk" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
KVKK Gizlilik Politikası →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="max-w-4xl mx-auto px-6 py-12">
|
||||||
|
<div className="prose prose-neutral dark:prose-invert max-w-none">
|
||||||
|
<h1 className="text-3xl font-bold mb-2">Kullanım Şartları</h1>
|
||||||
|
<p className="text-muted-foreground text-sm mb-8">Son güncelleme: 11 Mayıs 2026</p>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">1. Taraflar ve Kapsam</h2>
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
Bu Kullanım Şartları (“Şartlar”), <strong>Kovak Yazılım ve Medya LTD. ŞTİ.</strong>
|
||||||
|
(“Şirket”, “Biz”) ile <strong>emlak.kovakcrm.com</strong> üzerinden
|
||||||
|
sunulan <strong>Emlak CRM</strong> hizmetini (“Hizmet”) kullanan gerçek veya
|
||||||
|
tüzel kişi (“Kullanıcı”, “Siz”) arasındaki hukuki ilişkiyi
|
||||||
|
düzenlemektedir.
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground leading-relaxed mt-3">
|
||||||
|
Hizmetimize kaydolarak veya herhangi bir şekilde kullanarak bu Şartlar'ı okuduğunuzu,
|
||||||
|
anladığınızı ve kabul ettiğinizi beyan etmiş olursunuz. Şartları kabul etmiyorsanız
|
||||||
|
Hizmet'i kullanmaktan vazgeçiniz.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">2. Hizmetin Tanımı</h2>
|
||||||
|
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||||
|
Emlak CRM, emlak ofislerine ve gayrimenkul danışmanlarına yönelik bulut tabanlı
|
||||||
|
(“SaaS”) bir yazılım hizmetidir. Hizmet kapsamında sunulan başlıca özellikler:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside text-muted-foreground space-y-2 ml-4">
|
||||||
|
<li>Gayrimenkul portföy yönetimi (ilan ekleme, düzenleme, durum takibi)</li>
|
||||||
|
<li>Müşteri ilişkileri yönetimi (alıcı/kiracı/yatırımcı takibi)</li>
|
||||||
|
<li>Otomatik ilan-müşteri eşleştirme motoru</li>
|
||||||
|
<li>Paylaşılabilir sunum linkleri</li>
|
||||||
|
<li>Yatırımcı portalı</li>
|
||||||
|
<li>Finans ve komisyon takibi</li>
|
||||||
|
<li>Çok kullanıcılı ve çok ofisli (multi-tenant) yapı</li>
|
||||||
|
<li>Aktivite ve görev yönetimi</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-muted-foreground leading-relaxed mt-3">
|
||||||
|
Şirket, Hizmet kapsamını önceden bildirmek koşuluyla genişletme, değiştirme veya
|
||||||
|
belirli özellikleri kaldırma hakkını saklı tutar.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">3. Hesap Oluşturma ve Güvenlik</h2>
|
||||||
|
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||||
|
Hizmet'ten yararlanmak için geçerli bir e-posta adresi ile hesap oluşturmanız
|
||||||
|
gerekmektedir. Hesabınıza ilişkin şunları kabul edersiniz:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside text-muted-foreground space-y-2 ml-4">
|
||||||
|
<li>Kayıt sırasında sağlanan bilgilerin doğru, güncel ve eksiksiz olması</li>
|
||||||
|
<li>Şifrenizin gizli tutulması ve yetkisiz erişimlere karşı korunması</li>
|
||||||
|
<li>Hesabınız aracılığıyla gerçekleşen tüm işlemlerin sorumluluğunun size ait olması</li>
|
||||||
|
<li>Hesabınızın üçüncü kişilere devredilmemesi veya paylaşılmaması</li>
|
||||||
|
<li>Şüpheli bir erişim fark ettiğinizde derhal destek@kovakcrm.com adresine bildirim yapılması</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-muted-foreground leading-relaxed mt-3">
|
||||||
|
18 yaşından küçük kişiler Hizmet'i kullanamaz. Tüzel kişilik adına hesap açan gerçek
|
||||||
|
kişi, söz konusu tüzel kişiyi temsil etmeye yetkili olduğunu beyan eder.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">4. Abonelik ve Ödeme Koşulları</h2>
|
||||||
|
|
||||||
|
<h3 className="font-medium mb-2">4.1 Abonelik Planları</h3>
|
||||||
|
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||||
|
Emlak CRM ücretsiz deneme ve ücretli abonelik planları sunmaktadır. Güncel plan detayları
|
||||||
|
ve fiyatlandırma emlak.kovakcrm.com üzerinde yayımlanmaktadır.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="font-medium mb-2">4.2 Ödeme</h3>
|
||||||
|
<ul className="list-disc list-inside text-muted-foreground space-y-2 ml-4">
|
||||||
|
<li>Abonelik ücretleri belirtilen dönem başında (aylık veya yıllık) peşin tahsil edilir.</li>
|
||||||
|
<li>Tüm fiyatlar KDV hariç olup Türk Lirası (TRY) cinsinden belirtilir.</li>
|
||||||
|
<li>Ödeme başarısızlığında Şirket, hizmet erişimini geçici olarak askıya alabilir.</li>
|
||||||
|
<li>Fatura bilgileriniz 213 sayılı Vergi Usul Kanunu kapsamında 10 yıl saklanır.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="font-medium mt-4 mb-2">4.3 İptal ve İade</h3>
|
||||||
|
<ul className="list-disc list-inside text-muted-foreground space-y-2 ml-4">
|
||||||
|
<li>Aboneliğinizi dilediğiniz zaman iptal edebilirsiniz; iptal, mevcut dönemin sonunda geçerli olur.</li>
|
||||||
|
<li>Kıst iade yapılmaz; dönem başında ödenmiş ücret iade edilmez.</li>
|
||||||
|
<li>
|
||||||
|
6502 sayılı Tüketicinin Korunması Hakkında Kanun kapsamındaki cayma hakkı, ilk
|
||||||
|
abonelik başlangıcından itibaren 14 gün içinde kullanılabilir. Bu süre içinde
|
||||||
|
Hizmet henüz kullanılmadıysa tam iade yapılır.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">5. Kullanım Kuralları ve Yasaklar</h2>
|
||||||
|
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||||
|
Hizmet'i aşağıdaki amaçlarla kullanmak kesinlikle yasaktır:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside text-muted-foreground space-y-2 ml-4">
|
||||||
|
<li>Türk hukuku veya uluslararası mevzuata aykırı herhangi bir faaliyette bulunmak</li>
|
||||||
|
<li>Sahte veya yanıltıcı gayrimenkul ilanları yayımlamak</li>
|
||||||
|
<li>Başkasının kişisel verilerini rızası olmaksızın işlemek</li>
|
||||||
|
<li>Sistemin güvenliğini tehdit eden yazılım veya kod çalıştırmak</li>
|
||||||
|
<li>API veya otomatik araçlarla aşırı yük oluşturmak (DDoS benzeri eylemler)</li>
|
||||||
|
<li>Rakip bir ürün veya hizmet geliştirmek amacıyla Hizmet'i tersine mühendislik yapmak</li>
|
||||||
|
<li>Hizmet altyapısına yetkisiz erişim sağlamaya çalışmak</li>
|
||||||
|
<li>Fikri mülkiyet haklarını ihlal eden içerik yüklemek</li>
|
||||||
|
<li>Kullanıcıları taciz etmek veya kötüye kullanım niteliğinde eylemlerde bulunmak</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-muted-foreground leading-relaxed mt-3">
|
||||||
|
Bu kurallara aykırı kullanım tespit edildiğinde Şirket, önceden bildirim yapmaksızın
|
||||||
|
hesabı askıya alma veya kalıcı olarak kapatma hakkına sahiptir.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">6. Veriler, Gizlilik ve Veri Sahipliği</h2>
|
||||||
|
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||||
|
Hizmet'e yüklediğiniz tüm veriler (müşteri bilgileri, ilan içerikleri, belgeler vb.)
|
||||||
|
size ait olmaya devam eder. Şirket bu verileri yalnızca Hizmet'in sunulması amacıyla
|
||||||
|
işler; reklam veya üçüncü taraflara satış amacıyla kullanmaz.
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
Kişisel verilerin işlenmesine ilişkin ayrıntılı bilgi için{" "}
|
||||||
|
<Link href="/kvkk" className="text-primary hover:underline">
|
||||||
|
KVKK Aydınlatma Metni
|
||||||
|
</Link>
|
||||||
|
'ni incelemenizi rica ederiz.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">7. Fikri Mülkiyet Hakları</h2>
|
||||||
|
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||||
|
Emlak CRM markası, logosu, yazılım kodları, tasarım unsurları, kullanıcı arayüzü
|
||||||
|
ve dokümantasyonu dahil Hizmet'e ait tüm fikri mülkiyet hakları Kovak Yazılım ve
|
||||||
|
Medya LTD. ŞTİ.'ye aittir ve 5846 sayılı Fikir ve Sanat Eserleri Kanunu ile
|
||||||
|
ilgili uluslararası anlaşmalar kapsamında koruma altındadır.
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
Bu Şartlar, Kullanıcı'ya yalnızca Hizmet'i kendi iş faaliyetleri kapsamında
|
||||||
|
kullanmaya yönelik sınırlı, devredilemez ve münhasır olmayan bir lisans vermektedir.
|
||||||
|
Yazılımın kopyalanması, dağıtılması, alt lisans verilmesi veya türev çalışma
|
||||||
|
oluşturulması yasaktır.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">8. Hizmet Sürekliliği ve Garanti Reddi</h2>
|
||||||
|
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||||
|
Şirket, makul teknik imkânlar dahilinde Hizmet'in kesintisiz ve hatasız çalışmasını
|
||||||
|
hedefler. Bununla birlikte:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside text-muted-foreground space-y-2 ml-4">
|
||||||
|
<li>Planlı bakım çalışmaları önceden duyurularak gerçekleştirilir.</li>
|
||||||
|
<li>
|
||||||
|
Üçüncü taraf altyapı sağlayıcılarından kaynaklanan kesintiler dahil teknik arızalar
|
||||||
|
için Şirket, tazminat sorumluluğunu kabul etmez.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Hizmet, Türk Borçlar Kanunu'nun 219-231. maddeleri kapsamında “olduğu gibi”
|
||||||
|
sunulmaktadır; belirli bir amaca uygunluk garantisi verilmemektedir.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">9. Sorumluluk Sınırlaması</h2>
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
Yürürlükteki hukukun izin verdiği azami ölçüde, Şirket; dolaylı, arızi, özel,
|
||||||
|
sonuçsal veya cezai zararlardan (kâr kaybı, veri kaybı, itibar kaybı dahil) sorumlu
|
||||||
|
değildir. Şirket'in doğrudan sorumluluğu, zararın doğduğu tarihi takip eden son
|
||||||
|
12 ay içinde Kullanıcı'nın ödediği toplam abonelik ücretiyle sınırlıdır.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">10. Hesap Silme ve Hizmet Sonlandırma</h2>
|
||||||
|
|
||||||
|
<h3 className="font-medium mb-2">10.1 Kullanıcı Tarafından</h3>
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
Hesabınızı dilediğiniz zaman Ayarlar > Hesap bölümünden kapatabilir ya da
|
||||||
|
destek@kovakcrm.com adresine e-posta göndererek silme talebinde bulunabilirsiniz.
|
||||||
|
Silme işlemi 30 gün içinde tamamlanır; bu süre, faturalama ve yasal saklama
|
||||||
|
yükümlülükleri saklı kalmak kaydıyla verilerinizin de silinmesini kapsar.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="font-medium mt-4 mb-2">10.2 Şirket Tarafından</h3>
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
Şirket; bu Şartlar'ın ihlali, ödeme yapılmaması veya yasal zorunluluk hallerinde
|
||||||
|
önceden bildirim yaparak ya da ağır ihlallerde derhal hesabı kapatma hakkını saklı tutar.
|
||||||
|
Haksız fesih durumunda kalan dönem ücreti iade edilir.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">11. Değişiklikler</h2>
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
Şirket, bu Şartlar'ı gerektiğinde güncelleme hakkını saklı tutar. Önemli değişiklikler
|
||||||
|
yürürlük tarihinden en az 15 gün önce kayıtlı e-posta adresinize bildirilir ve
|
||||||
|
güncel metin her zaman <strong>emlak.kovakcrm.com/kullanim-sartlari</strong> adresinde
|
||||||
|
yayımlanır. Bildirimin ardından Hizmet'i kullanmaya devam etmeniz, değişiklikleri
|
||||||
|
kabul ettiğiniz anlamına gelir.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">12. Uygulanacak Hukuk ve Yetki</h2>
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
Bu Şartlar Türk hukukuna tabi olup Türk Borçlar Kanunu, Türk Ticaret Kanunu ve
|
||||||
|
6698 sayılı KVKK başta olmak üzere ilgili tüm Türk mevzuatı uygulanır.
|
||||||
|
Bu Şartlar'dan doğan her türlü uyuşmazlıkta <strong>İstanbul (Çağlayan) Mahkemeleri
|
||||||
|
ve İcra Daireleri</strong> yetkilidir.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">13. İletişim</h2>
|
||||||
|
<div className="p-4 rounded-lg bg-muted text-sm space-y-1">
|
||||||
|
<p><strong>Kovak Yazılım ve Medya LTD. ŞTİ.</strong></p>
|
||||||
|
<p>Teknik Destek: <a href="mailto:destek@kovakcrm.com" className="text-primary hover:underline">destek@kovakcrm.com</a></p>
|
||||||
|
<p>KVKK Başvuruları: <a href="mailto:kvkk@kovakcrm.com" className="text-primary hover:underline">kvkk@kovakcrm.com</a></p>
|
||||||
|
<p>Hukuki Bildirimler: <a href="mailto:hukuk@kovakcrm.com" className="text-primary hover:underline">hukuk@kovakcrm.com</a></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="border-t mt-12">
|
||||||
|
<div className="max-w-4xl mx-auto px-6 py-6 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||||
|
<p>© {new Date().getFullYear()} Kovak Yazılım ve Medya LTD. ŞTİ. — Emlak CRM</p>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Link href="/kvkk" className="hover:text-foreground transition-colors">KVKK</Link>
|
||||||
|
<Link href="/kullanim-sartlari" className="hover:text-foreground transition-colors">Kullanım Şartları</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Logo } from "@/components/logo";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "KVKK Gizlilik Politikası — Emlak CRM",
|
||||||
|
description:
|
||||||
|
"Kovak Yazılım ve Medya LTD. ŞTİ. tarafından işletilen Emlak CRM hizmetine ilişkin Kişisel Verilerin Korunması Kanunu (KVKK) kapsamında aydınlatma metni.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function KvkkPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="border-b">
|
||||||
|
<div className="max-w-4xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
|
<Link href="/" className="flex items-center gap-2">
|
||||||
|
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
|
||||||
|
<Logo size={18} />
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold">Emlak CRM</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/kullanim-sartlari" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
Kullanım Şartları →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="max-w-4xl mx-auto px-6 py-12">
|
||||||
|
<div className="prose prose-neutral dark:prose-invert max-w-none">
|
||||||
|
<h1 className="text-3xl font-bold mb-2">Kişisel Verilerin Korunması Kanunu (KVKK) Aydınlatma Metni</h1>
|
||||||
|
<p className="text-muted-foreground text-sm mb-8">Son güncelleme: 11 Mayıs 2026</p>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">1. Veri Sorumlusu</h2>
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
6698 sayılı Kişisel Verilerin Korunması Kanunu (“KVKK”) uyarınca kişisel verileriniz;
|
||||||
|
veri sorumlusu sıfatıyla <strong>Kovak Yazılım ve Medya LTD. ŞTİ.</strong> (“Şirket”,
|
||||||
|
“Biz”) tarafından aşağıda açıklanan amaç ve kapsamda işlenmektedir.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 p-4 rounded-lg bg-muted text-sm space-y-1">
|
||||||
|
<p><strong>Ünvan:</strong> Kovak Yazılım ve Medya LTD. ŞTİ.</p>
|
||||||
|
<p><strong>Hizmet:</strong> Emlak CRM (emlak.kovakcrm.com)</p>
|
||||||
|
<p><strong>E-posta:</strong> kvkk@kovakcrm.com</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">2. İşlenen Kişisel Veriler</h2>
|
||||||
|
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||||
|
Emlak CRM hizmetinin kullanımı kapsamında aşağıdaki kişisel veriler işlenebilmektedir:
|
||||||
|
</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium mb-2">a) Hesap ve Kimlik Bilgileri</h3>
|
||||||
|
<ul className="list-disc list-inside text-muted-foreground space-y-1 ml-4">
|
||||||
|
<li>Ad, soyad</li>
|
||||||
|
<li>E-posta adresi</li>
|
||||||
|
<li>Şifreli hesap bilgileri (şifreniz yalnızca tek yönlü şifrelenerek saklanır)</li>
|
||||||
|
<li>Profil fotoğrafı (isteğe bağlı)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium mb-2">b) İşletme Bilgileri</h3>
|
||||||
|
<ul className="list-disc list-inside text-muted-foreground space-y-1 ml-4">
|
||||||
|
<li>Emlak ofisi/şirket adı ve iletişim bilgileri</li>
|
||||||
|
<li>Ofis adresi, telefon numarası, web sitesi</li>
|
||||||
|
<li>Logo ve marka görselleri</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium mb-2">c) Müşteri ve Portföy Verileri</h3>
|
||||||
|
<ul className="list-disc list-inside text-muted-foreground space-y-1 ml-4">
|
||||||
|
<li>Sisteme eklenen müşteri adı, telefon, e-posta ve notları</li>
|
||||||
|
<li>Gayrimenkul ilan bilgileri (konum, fiyat, özellikler, görseller)</li>
|
||||||
|
<li>Satış ve kiralama işlem kayıtları</li>
|
||||||
|
<li>Müşteri arama kriterleri ve eşleşme kayıtları</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium mb-2">d) Kullanım ve Teknik Veriler</h3>
|
||||||
|
<ul className="list-disc list-inside text-muted-foreground space-y-1 ml-4">
|
||||||
|
<li>Oturum açma tarihleri ve IP adresi</li>
|
||||||
|
<li>Kullanılan tarayıcı ve işletim sistemi bilgisi</li>
|
||||||
|
<li>Uygulama içi eylem logları (denetim izleri)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium mb-2">e) Ödeme ve Fatura Bilgileri</h3>
|
||||||
|
<ul className="list-disc list-inside text-muted-foreground space-y-1 ml-4">
|
||||||
|
<li>Fatura adı/unvanı, adresi ve vergi numarası</li>
|
||||||
|
<li>Ödeme onay referans numarası</li>
|
||||||
|
<li>Kart bilgileri tarafımızca saklanmaz; ödeme altyapı sağlayıcımız tarafından PCI-DSS uyumlu biçimde işlenir</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">3. Kişisel Verilerin İşlenme Amaçları</h2>
|
||||||
|
<ul className="list-disc list-inside text-muted-foreground space-y-2 ml-4">
|
||||||
|
<li>Emlak CRM hesabının oluşturulması, doğrulanması ve yönetilmesi</li>
|
||||||
|
<li>Çok kiracılı (multi-tenant) yapıda veri izolasyonunun sağlanması</li>
|
||||||
|
<li>Portföy, müşteri, aktivite ve finans modüllerinin çalıştırılması</li>
|
||||||
|
<li>Otomatik eşleştirme motorunun ilan-müşteri kriterleri üzerinden çalıştırılması</li>
|
||||||
|
<li>Paylaşılabilir sunum linkleri ve yatırımcı portalı hizmetinin sunulması</li>
|
||||||
|
<li>Abonelik ve ödeme işlemlerinin yürütülmesi</li>
|
||||||
|
<li>Teknik destek ve kullanıcı bildirimleri</li>
|
||||||
|
<li>Bilgi güvenliği, dolandırıcılık önleme ve yasal yükümlülüklerin yerine getirilmesi</li>
|
||||||
|
<li>Hizmet kalitesinin ölçülmesi ve iyileştirilmesi</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">4. Hukuki Dayanak</h2>
|
||||||
|
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||||
|
Kişisel verileriniz KVKK'nın 5. ve 6. maddeleri uyarınca aşağıdaki hukuki dayanaklara istinaden işlenmektedir:
|
||||||
|
</p>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="text-left py-2 pr-4 font-medium">İşleme Amacı</th>
|
||||||
|
<th className="text-left py-2 font-medium">Hukuki Dayanak</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="text-muted-foreground">
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-2 pr-4">Hesap oluşturma ve hizmet sunumu</td>
|
||||||
|
<td className="py-2">Sözleşmenin kurulması veya ifası (KVKK m.5/2-c)</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-2 pr-4">Ödeme ve fatura işlemleri</td>
|
||||||
|
<td className="py-2">Sözleşmenin ifası; hukuki yükümlülük (KVKK m.5/2-ç)</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-2 pr-4">Güvenlik ve denetim logları</td>
|
||||||
|
<td className="py-2">Meşru menfaat (KVKK m.5/2-f)</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-2 pr-4">Pazarlama ve ürün bildirimleri</td>
|
||||||
|
<td className="py-2">Açık rıza (KVKK m.5/1)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 pr-4">Yasal bildirim yükümlülükleri</td>
|
||||||
|
<td className="py-2">Kanunda öngörülme (KVKK m.5/2-a)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">5. Kişisel Verilerin Aktarımı</h2>
|
||||||
|
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||||
|
Kişisel verileriniz, hizmetin sunulabilmesi için aşağıdaki üçüncü taraflarla paylaşılabilir:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside text-muted-foreground space-y-2 ml-4">
|
||||||
|
<li>
|
||||||
|
<strong>Appwrite (Altyapı Sağlayıcı):</strong> Kimlik doğrulama, veritabanı ve depolama
|
||||||
|
hizmetleri için kullanılmaktadır. Veriler Appwrite'ın sağladığı sunucularda şifreli
|
||||||
|
biçimde saklanmaktadır.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Ödeme Altyapı Sağlayıcıları:</strong> Abonelik ödemelerinin işlenmesi amacıyla
|
||||||
|
yalnızca gerekli bilgiler aktarılır; kart bilgileri Şirket tarafından saklanmaz.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Yetkili Kamu Kurumları:</strong> Yasal zorunluluk veya mahkeme kararı durumunda
|
||||||
|
ilgili mevzuat çerçevesinde paylaşım yapılabilir.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-muted-foreground leading-relaxed mt-3">
|
||||||
|
Kişisel verileriniz, yukarıda sayılanlar dışında üçüncü kişilerle reklam amaçlı paylaşılmaz
|
||||||
|
ve satılmaz.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">6. Kişisel Verilerin Saklanma Süresi</h2>
|
||||||
|
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||||
|
Kişisel verileriniz, işleme amacının gerektirdiği süre boyunca ve yasal saklama
|
||||||
|
yükümlülüklerine uygun olarak tutulur:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside text-muted-foreground space-y-2 ml-4">
|
||||||
|
<li>Hesap ve profil bilgileri: Hesabın aktif olduğu süre + silinme talebinden itibaren 30 gün</li>
|
||||||
|
<li>Fatura ve ödeme kayıtları: Vergi mevzuatı gereği 10 yıl</li>
|
||||||
|
<li>Güvenlik logları: 2 yıl</li>
|
||||||
|
<li>Pazarlama izni: Geri alınana kadar</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">7. Çerezler (Cookie) Politikası</h2>
|
||||||
|
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||||
|
Emlak CRM yalnızca aşağıdaki amaçlarla zorunlu çerezler kullanmaktadır:
|
||||||
|
</p>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="text-left py-2 pr-4 font-medium">Çerez Adı</th>
|
||||||
|
<th className="text-left py-2 pr-4 font-medium">Amaç</th>
|
||||||
|
<th className="text-left py-2 font-medium">Süre</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="text-muted-foreground">
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">isletmem-session</td>
|
||||||
|
<td className="py-2 pr-4">Kullanıcı oturum doğrulaması</td>
|
||||||
|
<td className="py-2">Oturum süresi</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">isletmem-tenant</td>
|
||||||
|
<td className="py-2 pr-4">Aktif çalışma alanı seçimi</td>
|
||||||
|
<td className="py-2">1 yıl</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">isletmem-ui-theme</td>
|
||||||
|
<td className="py-2 pr-4">Tema tercihi (açık/koyu)</td>
|
||||||
|
<td className="py-2">Kalıcı (localStorage)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground leading-relaxed mt-3">
|
||||||
|
Hizmetimiz üçüncü taraf reklam veya izleme çerezleri kullanmamaktadır.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">8. Veri Sahibinin Hakları (KVKK Madde 11)</h2>
|
||||||
|
<p className="text-muted-foreground leading-relaxed mb-3">
|
||||||
|
KVKK'nın 11. maddesi uyarınca veri sahibi olarak aşağıdaki haklara sahipsiniz:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside text-muted-foreground space-y-2 ml-4">
|
||||||
|
<li>Kişisel verilerinizin işlenip işlenmediğini öğrenme</li>
|
||||||
|
<li>İşlenmişse buna ilişkin bilgi talep etme</li>
|
||||||
|
<li>İşlenme amacını ve amacına uygun kullanılıp kullanılmadığını öğrenme</li>
|
||||||
|
<li>Yurt içinde veya yurt dışında aktarıldığı üçüncü kişileri öğrenme</li>
|
||||||
|
<li>Eksik veya yanlış işlenmişse düzeltilmesini isteme</li>
|
||||||
|
<li>İşleme amacının ortadan kalkması halinde silinmesini veya yok edilmesini isteme</li>
|
||||||
|
<li>Düzeltme, silme ve yok etme işlemlerinin aktarılan üçüncü kişilere bildirilmesini isteme</li>
|
||||||
|
<li>Münhasıran otomatik sistemler vasıtasıyla oluşan sonuca itiraz etme</li>
|
||||||
|
<li>Kanuna aykırı işleme nedeniyle zararın giderilmesini talep etme</li>
|
||||||
|
</ul>
|
||||||
|
<div className="mt-4 p-4 rounded-lg bg-muted text-sm">
|
||||||
|
<p className="font-medium mb-1">Başvuru Yöntemi</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Haklarınızı kullanmak için <strong>kvkk@kovakcrm.com</strong> adresine kimliğinizi
|
||||||
|
doğrular nitelikte bilgiler içeren yazılı başvurunuzu iletebilirsiniz. Başvurular
|
||||||
|
KVKK'nın 13. maddesi ve ilgili tebliğ gereğince en geç 30 gün içinde yanıtlanır.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">9. Veri Güvenliği</h2>
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
Kovak Yazılım ve Medya LTD. ŞTİ., kişisel verilerinizin güvenliğini sağlamak amacıyla
|
||||||
|
teknik ve idari tedbirler almaktadır: TLS/HTTPS şifrelemesi, güvenli parola saklama
|
||||||
|
(bcrypt hash), erişim denetimi ve denetim logları bunların başında gelmektedir.
|
||||||
|
Güvenlik ihlali durumunda KVKK'nın 12. maddesi uyarınca Kurul'a bildirim yapılır
|
||||||
|
ve etkilenen kullanıcılar ivedilikle bilgilendirilir.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">10. Politika Değişiklikleri</h2>
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
Bu metin, yasal düzenlemeler veya hizmet değişiklikleri doğrultusunda güncellenebilir.
|
||||||
|
Önemli değişiklikler e-posta yoluyla bildirilir; güncel metin her zaman
|
||||||
|
<strong> emlak.kovakcrm.com/kvkk</strong> adresinde yayımlanır.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">11. İletişim</h2>
|
||||||
|
<div className="p-4 rounded-lg bg-muted text-sm space-y-1">
|
||||||
|
<p><strong>Kovak Yazılım ve Medya LTD. ŞTİ.</strong></p>
|
||||||
|
<p>KVKK Başvuruları: <a href="mailto:kvkk@kovakcrm.com" className="text-primary hover:underline">kvkk@kovakcrm.com</a></p>
|
||||||
|
<p>Genel İletişim: <a href="mailto:destek@kovakcrm.com" className="text-primary hover:underline">destek@kovakcrm.com</a></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="border-t mt-12">
|
||||||
|
<div className="max-w-4xl mx-auto px-6 py-6 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||||
|
<p>© {new Date().getFullYear()} Kovak Yazılım ve Medya LTD. ŞTİ. — Emlak CRM</p>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Link href="/kvkk" className="hover:text-foreground transition-colors">KVKK</Link>
|
||||||
|
<Link href="/kullanim-sartlari" className="hover:text-foreground transition-colors">Kullanım Şartları</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { CardDecorator } from '@/components/ui/card-decorator'
|
import { CardDecorator } from '@/components/ui/card-decorator'
|
||||||
import { Github, Code, Palette, Layout, Crown } from 'lucide-react'
|
import { GithubLogo, Code, Palette, Layout, Crown } from '@/lib/icons'
|
||||||
|
|
||||||
const values = [
|
const values = [
|
||||||
{
|
{
|
||||||
@@ -72,7 +72,7 @@ export function AboutSection() {
|
|||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
<Button size="lg" className="cursor-pointer" asChild>
|
<Button size="lg" className="cursor-pointer" asChild>
|
||||||
<a href="https://github.com/silicondeck/shadcn-dashboard-landing-template" target="_blank" rel="noopener noreferrer">
|
<a href="https://github.com/silicondeck/shadcn-dashboard-landing-template" target="_blank" rel="noopener noreferrer">
|
||||||
<Github className="mr-2 h-4 w-4" />
|
<GithubLogo className="mr-2 h-4 w-4" />
|
||||||
Star on GitHub
|
Star on GitHub
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { ArrowRight } from 'lucide-react'
|
import { ArrowRight } from '@/lib/icons'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form"
|
} from "@/components/ui/form"
|
||||||
import { Mail, MessageCircle, Github, BookOpen } from 'lucide-react'
|
import { Envelope, ChatCircle, GithubLogo, BookOpen } from '@/lib/icons'
|
||||||
|
|
||||||
const contactFormSchema = z.object({
|
const contactFormSchema = z.object({
|
||||||
firstName: z.string().min(2, {
|
firstName: z.string().min(2, {
|
||||||
@@ -74,7 +74,7 @@ export function ContactSection() {
|
|||||||
<Card className="hover:shadow-md transition-shadow cursor-pointer">
|
<Card className="hover:shadow-md transition-shadow cursor-pointer">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<MessageCircle className="h-5 w-5 text-primary" />
|
<ChatCircle className="h-5 w-5 text-primary" />
|
||||||
Discord Community
|
Discord Community
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -93,7 +93,7 @@ export function ContactSection() {
|
|||||||
<Card className="hover:shadow-md transition-shadow cursor-pointer">
|
<Card className="hover:shadow-md transition-shadow cursor-pointer">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Github className="h-5 w-5 text-primary" />
|
<GithubLogo className="h-5 w-5 text-primary" />
|
||||||
GitHub Issues
|
GitHub Issues
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -134,7 +134,7 @@ export function ContactSection() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Mail className="h-5 w-5" />
|
<Envelope className="h-5 w-5" />
|
||||||
Send us a message
|
Send us a message
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { ArrowRight, TrendingUp, Package, Github } from 'lucide-react'
|
import { ArrowRight, TrendUp, Package, GithubLogo } from '@/lib/icons'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
@@ -15,14 +15,14 @@ export function CTASection() {
|
|||||||
{/* Badge and Stats */}
|
{/* Badge and Stats */}
|
||||||
<div className='flex flex-col items-center gap-4'>
|
<div className='flex flex-col items-center gap-4'>
|
||||||
<Badge variant='outline' className='flex items-center gap-2'>
|
<Badge variant='outline' className='flex items-center gap-2'>
|
||||||
<TrendingUp className='size-3' />
|
<TrendUp className='size-3' />
|
||||||
Productivity Suite
|
Productivity Suite
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<div className='text-muted-foreground flex items-center gap-4 text-sm'>
|
<div className='text-muted-foreground flex items-center gap-4 text-sm'>
|
||||||
<span className='flex items-center gap-1'>
|
<span className='flex items-center gap-1'>
|
||||||
<div className='size-2 rounded-full bg-green-500' />
|
<div className='size-2 rounded-full bg-green-500' />
|
||||||
150+ Blocks
|
150+ SquaresFour
|
||||||
</span>
|
</span>
|
||||||
<Separator orientation='vertical' className='!h-4' />
|
<Separator orientation='vertical' className='!h-4' />
|
||||||
<span>25K+ Downloads</span>
|
<span>25K+ Downloads</span>
|
||||||
@@ -62,7 +62,7 @@ export function CTASection() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button variant='outline' size='lg' className='cursor-pointer px-8 py-6 text-lg font-medium group' asChild>
|
<Button variant='outline' size='lg' className='cursor-pointer px-8 py-6 text-lg font-medium group' asChild>
|
||||||
<a href='https://github.com/silicondeck/shadcn-dashboard-landing-template' target='_blank' rel='noopener noreferrer'>
|
<a href='https://github.com/silicondeck/shadcn-dashboard-landing-template' target='_blank' rel='noopener noreferrer'>
|
||||||
<Github className='me-2 size-5' />
|
<GithubLogo className='me-2 size-5' />
|
||||||
View on GitHub
|
View on GitHub
|
||||||
<ArrowRight className='ms-2 size-4 transition-transform group-hover:translate-x-1' />
|
<ArrowRight className='ms-2 size-4 transition-transform group-hover:translate-x-1' />
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { CircleHelp } from 'lucide-react'
|
import { Question } from '@/lib/icons'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -75,7 +75,7 @@ const FaqSection = () => {
|
|||||||
<AccordionTrigger className='cursor-pointer items-center gap-4 rounded-none bg-transparent py-2 ps-3 pe-4 hover:no-underline data-[state=open]:border-b'>
|
<AccordionTrigger className='cursor-pointer items-center gap-4 rounded-none bg-transparent py-2 ps-3 pe-4 hover:no-underline data-[state=open]:border-b'>
|
||||||
<div className='flex items-center gap-4'>
|
<div className='flex items-center gap-4'>
|
||||||
<div className='bg-primary/10 text-primary flex size-9 shrink-0 items-center justify-center rounded-full'>
|
<div className='bg-primary/10 text-primary flex size-9 shrink-0 items-center justify-center rounded-full'>
|
||||||
<CircleHelp className='size-5' />
|
<Question className='size-5' />
|
||||||
</div>
|
</div>
|
||||||
<span className='text-start font-semibold'>{item.question}</span>
|
<span className='text-start font-semibold'>{item.question}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
ChartBar,
|
||||||
Zap,
|
Lightning,
|
||||||
Users,
|
Users,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Database,
|
Database,
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
Crown,
|
Crown,
|
||||||
Layout,
|
Layout,
|
||||||
Palette
|
Palette
|
||||||
} from 'lucide-react'
|
} from '@/lib/icons'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Image3D } from '@/components/image-3d'
|
import { Image3D } from '@/components/image-3d'
|
||||||
@@ -32,7 +32,7 @@ const mainFeatures = [
|
|||||||
description: 'Copy-paste components that just work out of the box.'
|
description: 'Copy-paste components that just work out of the box.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Zap,
|
icon: Lightning,
|
||||||
title: 'Regular Updates',
|
title: 'Regular Updates',
|
||||||
description: 'New blocks and templates added weekly to keep you current.'
|
description: 'New blocks and templates added weekly to keep you current.'
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@ const mainFeatures = [
|
|||||||
|
|
||||||
const secondaryFeatures = [
|
const secondaryFeatures = [
|
||||||
{
|
{
|
||||||
icon: BarChart3,
|
icon: ChartBar,
|
||||||
title: 'Multiple Frameworks',
|
title: 'Multiple Frameworks',
|
||||||
description: 'React, Next.js, and Vite compatibility for flexible development.'
|
description: 'React, Next.js, and Vite compatibility for flexible development.'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form"
|
} from "@/components/ui/form"
|
||||||
import { Logo } from '@/components/logo'
|
import { Logo } from '@/components/logo'
|
||||||
import { Github, Twitter, Linkedin, Youtube, Heart } from 'lucide-react'
|
import { GithubLogo, TwitterLogo, LinkedinLogo, YoutubeLogo, Heart } from '@/lib/icons'
|
||||||
|
|
||||||
const newsletterSchema = z.object({
|
const newsletterSchema = z.object({
|
||||||
email: z.string().email({
|
email: z.string().email({
|
||||||
@@ -50,10 +50,10 @@ const footerLinks = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const socialLinks = [
|
const socialLinks = [
|
||||||
{ name: 'Twitter', href: '#', icon: Twitter },
|
{ name: 'TwitterLogo', href: '#', icon: TwitterLogo },
|
||||||
{ name: 'GitHub', href: 'https://github.com/silicondeck/shadcn-dashboard-landing-template', icon: Github },
|
{ name: 'GitHub', href: 'https://github.com/silicondeck/shadcn-dashboard-landing-template', icon: GithubLogo },
|
||||||
{ name: 'LinkedIn', href: '#', icon: Linkedin },
|
{ name: 'LinkedIn', href: '#', icon: LinkedinLogo },
|
||||||
{ name: 'YouTube', href: '#', icon: Youtube },
|
{ name: 'YouTube', href: '#', icon: YoutubeLogo },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function LandingFooter() {
|
export function LandingFooter() {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { ArrowRight, Play, Star } from 'lucide-react'
|
import { ArrowRight, Play, Star } from '@/lib/icons'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { DotPattern } from '@/components/dot-pattern'
|
import { DotPattern } from '@/components/dot-pattern'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Palette, RotateCcw, Settings, X, Dices, Upload, ExternalLink, Sun, Moon } from 'lucide-react'
|
import { Palette, ArrowCounterClockwise, GearSix, X, Shuffle, Upload, ArrowSquareOut, Sun, Moon } from '@/lib/icons'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
@@ -136,12 +136,12 @@ export function LandingThemeCustomizer({ open, onOpenChange }: LandingThemeCusto
|
|||||||
<SheetHeader className="space-y-0 p-4 pb-2">
|
<SheetHeader className="space-y-0 p-4 pb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="p-2 bg-primary/10 rounded-lg">
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
<Settings className="h-4 w-4" />
|
<GearSix className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<SheetTitle className="text-lg font-semibold">Theme Customizer</SheetTitle>
|
<SheetTitle className="text-lg font-semibold">Theme Customizer</SheetTitle>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<Button variant="outline" size="icon" onClick={handleReset} className="cursor-pointer h-8 w-8">
|
<Button variant="outline" size="icon" onClick={handleReset} className="cursor-pointer h-8 w-8">
|
||||||
<RotateCcw className="h-4 w-4" />
|
<ArrowCounterClockwise className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="icon" onClick={() => onOpenChange(false)} className="cursor-pointer h-8 w-8">
|
<Button variant="outline" size="icon" onClick={() => onOpenChange(false)} className="cursor-pointer h-8 w-8">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
@@ -186,7 +186,7 @@ export function LandingThemeCustomizer({ open, onOpenChange }: LandingThemeCusto
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-sm font-medium">Shadcn UI Theme Presets</Label>
|
<Label className="text-sm font-medium">Shadcn UI Theme Presets</Label>
|
||||||
<Button variant="outline" size="sm" onClick={handleRandomShadcn} className="cursor-pointer">
|
<Button variant="outline" size="sm" onClick={handleRandomShadcn} className="cursor-pointer">
|
||||||
<Dices className="h-3.5 w-3.5 mr-1.5" />
|
<Shuffle className="h-3.5 w-3.5 mr-1.5" />
|
||||||
Random
|
Random
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,7 +240,7 @@ export function LandingThemeCustomizer({ open, onOpenChange }: LandingThemeCusto
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-sm font-medium">Tweakcn Theme Presets</Label>
|
<Label className="text-sm font-medium">Tweakcn Theme Presets</Label>
|
||||||
<Button variant="outline" size="sm" onClick={handleRandomTweakcn} className="cursor-pointer">
|
<Button variant="outline" size="sm" onClick={handleRandomTweakcn} className="cursor-pointer">
|
||||||
<Dices className="h-3.5 w-3.5 mr-1.5" />
|
<Shuffle className="h-3.5 w-3.5 mr-1.5" />
|
||||||
Random
|
Random
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -373,7 +373,7 @@ export function LandingThemeCustomizer({ open, onOpenChange }: LandingThemeCusto
|
|||||||
className="w-full cursor-pointer"
|
className="w-full cursor-pointer"
|
||||||
onClick={() => window.open('https://tweakcn.com/editor/theme', '_blank')}
|
onClick={() => window.open('https://tweakcn.com/editor/theme', '_blank')}
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-3.5 w-3.5 mr-1.5" />
|
<ArrowSquareOut className="h-3.5 w-3.5 mr-1.5" />
|
||||||
Open Tweakcn
|
Open Tweakcn
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -400,7 +400,7 @@ export function LandingThemeCustomizerTrigger({ onClick }: { onClick: () => void
|
|||||||
"fixed top-1/2 -translate-y-1/2 h-12 w-12 rounded-full shadow-lg z-50 bg-primary hover:bg-primary/90 text-primary-foreground cursor-pointer right-4"
|
"fixed top-1/2 -translate-y-1/2 h-12 w-12 rounded-full shadow-lg z-50 bg-primary hover:bg-primary/90 text-primary-foreground cursor-pointer right-4"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Settings className="h-5 w-5" />
|
<GearSix className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const techCompanies = [
|
|||||||
{ name: 'Dropbox', id: 'dropbox' },
|
{ name: 'Dropbox', id: 'dropbox' },
|
||||||
{ name: 'Stripe', id: 'stripe' },
|
{ name: 'Stripe', id: 'stripe' },
|
||||||
{ name: 'Google', id: 'google' },
|
{ name: 'Google', id: 'google' },
|
||||||
{ name: 'Apple', id: 'apple' },
|
{ name: 'AppleLogo', id: 'apple' },
|
||||||
{ name: 'Meta', id: 'meta' },
|
{ name: 'Meta', id: 'meta' },
|
||||||
{ name: 'Tesla', id: 'tesla' },
|
{ name: 'Tesla', id: 'tesla' },
|
||||||
{ name: 'Salesforce', id: 'salesforce' },
|
{ name: 'Salesforce', id: 'salesforce' },
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Menu, Github, LayoutDashboard, ChevronDown, X, Moon, Sun } from 'lucide-react'
|
import { List, GithubLogo, SquaresFour, CaretDown, X, Moon, Sun } from '@/lib/icons'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
NavigationMenu,
|
NavigationMenu,
|
||||||
@@ -30,7 +30,7 @@ import { ModeToggle } from '@/components/mode-toggle'
|
|||||||
import { useTheme } from '@/hooks/use-theme'
|
import { useTheme } from '@/hooks/use-theme'
|
||||||
|
|
||||||
const navigationItems = [
|
const navigationItems = [
|
||||||
{ name: 'Home', href: '#hero' },
|
{ name: 'House', href: '#hero' },
|
||||||
{ name: 'Features', href: '#features' },
|
{ name: 'Features', href: '#features' },
|
||||||
{ name: 'Solutions', href: '#features', hasMegaMenu: true },
|
{ name: 'Solutions', href: '#features', hasMegaMenu: true },
|
||||||
{ name: 'Team', href: '#team' },
|
{ name: 'Team', href: '#team' },
|
||||||
@@ -42,7 +42,7 @@ const navigationItems = [
|
|||||||
// Solutions menu items for mobile
|
// Solutions menu items for mobile
|
||||||
const solutionsItems = [
|
const solutionsItems = [
|
||||||
{ title: 'Browse Products' },
|
{ title: 'Browse Products' },
|
||||||
{ name: 'Free Blocks', href: '#free-blocks' },
|
{ name: 'Free SquaresFour', href: '#free-blocks' },
|
||||||
{ name: 'Premium Templates', href: '#premium-templates' },
|
{ name: 'Premium Templates', href: '#premium-templates' },
|
||||||
{ name: 'Admin Dashboards', href: '#admin-dashboards' },
|
{ name: 'Admin Dashboards', href: '#admin-dashboards' },
|
||||||
{ name: 'Landing Pages', href: '#landing-pages' },
|
{ name: 'Landing Pages', href: '#landing-pages' },
|
||||||
@@ -128,12 +128,12 @@ export function LandingNavbar() {
|
|||||||
<ModeToggle variant="ghost" />
|
<ModeToggle variant="ghost" />
|
||||||
<Button variant="ghost" size="icon" asChild className="cursor-pointer">
|
<Button variant="ghost" size="icon" asChild className="cursor-pointer">
|
||||||
<a href="https://github.com/silicondeck/shadcn-dashboard-landing-template" target="_blank" rel="noopener noreferrer" aria-label="GitHub Repository">
|
<a href="https://github.com/silicondeck/shadcn-dashboard-landing-template" target="_blank" rel="noopener noreferrer" aria-label="GitHub Repository">
|
||||||
<Github className="h-5 w-5" />
|
<GithubLogo className="h-5 w-5" />
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" asChild className="cursor-pointer">
|
<Button variant="outline" asChild className="cursor-pointer">
|
||||||
<Link href="/dashboard" target="_blank" rel="noopener noreferrer">
|
<Link href="/dashboard" target="_blank" rel="noopener noreferrer">
|
||||||
<LayoutDashboard className="h-4 w-4 mr-2" />
|
<SquaresFour className="h-4 w-4 mr-2" />
|
||||||
Dashboard
|
Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -145,11 +145,11 @@ export function LandingNavbar() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Menu */}
|
{/* Mobile List */}
|
||||||
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<SheetTrigger asChild className="xl:hidden">
|
<SheetTrigger asChild className="xl:hidden">
|
||||||
<Button variant="ghost" size="icon" className="cursor-pointer">
|
<Button variant="ghost" size="icon" className="cursor-pointer">
|
||||||
<Menu className="h-5 w-5" />
|
<List className="h-5 w-5" />
|
||||||
<span className="sr-only">Toggle menu</span>
|
<span className="sr-only">Toggle menu</span>
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
@@ -174,7 +174,7 @@ export function LandingNavbar() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" asChild className="cursor-pointer h-8 w-8">
|
<Button variant="ghost" size="icon" asChild className="cursor-pointer h-8 w-8">
|
||||||
<a href="https://github.com/silicondeck/shadcn-dashboard-landing-template" target="_blank" rel="noopener noreferrer" aria-label="GitHub Repository">
|
<a href="https://github.com/silicondeck/shadcn-dashboard-landing-template" target="_blank" rel="noopener noreferrer" aria-label="GitHub Repository">
|
||||||
<Github className="h-4 w-4" />
|
<GithubLogo className="h-4 w-4" />
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" onClick={() => setIsOpen(false)} className="cursor-pointer h-8 w-8">
|
<Button variant="ghost" size="icon" onClick={() => setIsOpen(false)} className="cursor-pointer h-8 w-8">
|
||||||
@@ -193,7 +193,7 @@ export function LandingNavbar() {
|
|||||||
<Collapsible open={solutionsOpen} onOpenChange={setSolutionsOpen}>
|
<Collapsible open={solutionsOpen} onOpenChange={setSolutionsOpen}>
|
||||||
<CollapsibleTrigger className="flex items-center justify-between w-full px-4 py-3 text-base font-medium rounded-lg transition-colors hover:bg-accent hover:text-accent-foreground cursor-pointer">
|
<CollapsibleTrigger className="flex items-center justify-between w-full px-4 py-3 text-base font-medium rounded-lg transition-colors hover:bg-accent hover:text-accent-foreground cursor-pointer">
|
||||||
{item.name}
|
{item.name}
|
||||||
<ChevronDown className={`h-4 w-4 transition-transform ${solutionsOpen ? 'rotate-180' : ''}`} />
|
<CaretDown className={`h-4 w-4 transition-transform ${solutionsOpen ? 'rotate-180' : ''}`} />
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="pl-4 space-y-1">
|
<CollapsibleContent className="pl-4 space-y-1">
|
||||||
{solutionsItems.map((solution, index) => (
|
{solutionsItems.map((solution, index) => (
|
||||||
@@ -250,7 +250,7 @@ export function LandingNavbar() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Button variant="outline" size="lg" asChild className="w-full cursor-pointer">
|
<Button variant="outline" size="lg" asChild className="w-full cursor-pointer">
|
||||||
<Link href="/dashboard">
|
<Link href="/dashboard">
|
||||||
<LayoutDashboard className="size-4" />
|
<SquaresFour className="size-4" />
|
||||||
Dashboard
|
Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Check } from 'lucide-react'
|
import { Check } from '@/lib/icons'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||||
@@ -101,7 +101,7 @@ export function PricingSection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
<span className="text-primary font-semibold">Save 20%</span> On Annual Billing
|
<span className="text-primary font-semibold">FloppyDisk 20%</span> On Annual Billing
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Users,
|
Users,
|
||||||
Star
|
Star
|
||||||
} from 'lucide-react'
|
} from '@/lib/icons'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { DotPattern } from '@/components/dot-pattern'
|
import { DotPattern } from '@/components/dot-pattern'
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { CardDecorator } from '@/components/ui/card-decorator'
|
import { CardDecorator } from '@/components/ui/card-decorator'
|
||||||
import { Github, Linkedin, Globe } from 'lucide-react'
|
import { GithubLogo, LinkedinLogo, Globe } from '@/lib/icons'
|
||||||
|
|
||||||
|
|
||||||
const team = [
|
const team = [
|
||||||
@@ -91,7 +91,7 @@ const team = [
|
|||||||
id: 7,
|
id: 7,
|
||||||
name: 'James Anderson',
|
name: 'James Anderson',
|
||||||
role: 'UX Researcher',
|
role: 'UX Researcher',
|
||||||
description: 'Lead user research for Slack. Contractor for Netflix and Udacity.',
|
description: 'Lead user research for SlackLogo. Contractor for Netflix and Udacity.',
|
||||||
image: 'https://images.unsplash.com/photo-1566492031773-4f4e44671d66?q=60&w=150&auto=format&fit=crop',
|
image: 'https://images.unsplash.com/photo-1566492031773-4f4e44671d66?q=60&w=150&auto=format&fit=crop',
|
||||||
fallback: 'JA',
|
fallback: 'JA',
|
||||||
social: {
|
social: {
|
||||||
@@ -181,7 +181,7 @@ export function TeamSection() {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
aria-label={`${member.name} LinkedIn`}
|
aria-label={`${member.name} LinkedIn`}
|
||||||
>
|
>
|
||||||
<Linkedin className="h-4 w-4" />
|
<LinkedinLogo className="h-4 w-4" />
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -196,7 +196,7 @@ export function TeamSection() {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
aria-label={`${member.name} GitHub`}
|
aria-label={`${member.name} GitHub`}
|
||||||
>
|
>
|
||||||
<Github className="h-4 w-4" />
|
<GithubLogo className="h-4 w-4" />
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
+2
-2
@@ -7,8 +7,8 @@ import { SidebarConfigProvider } from "@/contexts/sidebar-context";
|
|||||||
import { inter } from "@/lib/fonts";
|
import { inter } from "@/lib/fonts";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "İşletmem KovakCRM",
|
title: "Emlak CRM — Kovak Yazılım ve Medya",
|
||||||
description: "Multi-tenant CRM — KovakSoft",
|
description: "Emlak ofisleri için çok kiracılı CRM — portföy, müşteri, finans ve eşleştirme.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState } from "react";
|
import { useActionState } from "react";
|
||||||
import { Building2, Loader2, ShieldCheck, ArrowRight } from "lucide-react";
|
import { Buildings, CircleNotch, ShieldCheck, ArrowRight } from '@/lib/icons';
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -54,11 +54,11 @@ export function CreateWorkspaceForm({ userName, crossAppTeams = [] }: Props) {
|
|||||||
className="flex w-full items-center justify-between rounded-lg border bg-background px-4 py-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-60"
|
className="flex w-full items-center justify-between rounded-lg border bg-background px-4 py-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<Building2 className="size-4 text-muted-foreground" />
|
<Buildings className="size-4 text-muted-foreground" />
|
||||||
<span>{team.name}</span>
|
<span>{team.name}</span>
|
||||||
</div>
|
</div>
|
||||||
{isImporting ? (
|
{isImporting ? (
|
||||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
<CircleNotch className="size-4 animate-spin text-muted-foreground" />
|
||||||
) : (
|
) : (
|
||||||
<ArrowRight className="size-4 text-muted-foreground" />
|
<ArrowRight className="size-4 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
@@ -83,7 +83,7 @@ export function CreateWorkspaceForm({ userName, crossAppTeams = [] }: Props) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<div className="bg-primary/10 text-primary mx-auto mb-2 flex size-12 items-center justify-center rounded-full">
|
<div className="bg-primary/10 text-primary mx-auto mb-2 flex size-12 items-center justify-center rounded-full">
|
||||||
<Building2 className="size-6" />
|
<Buildings className="size-6" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-2xl">
|
<CardTitle className="text-2xl">
|
||||||
{userName ? `Hoş geldiniz, ${userName}` : "Çalışma alanı oluştur"}
|
{userName ? `Hoş geldiniz, ${userName}` : "Çalışma alanı oluştur"}
|
||||||
@@ -129,7 +129,7 @@ export function CreateWorkspaceForm({ userName, crossAppTeams = [] }: Props) {
|
|||||||
<Button type="submit" className="w-full" disabled={isPending}>
|
<Button type="submit" className="w-full" disabled={isPending}>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<CircleNotch className="size-4 animate-spin" />
|
||||||
Hazırlanıyor...
|
Hazırlanıyor...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Query } from "node-appwrite";
|
import { Query } from "node-appwrite";
|
||||||
import { Building2, MapPin, Home } from "lucide-react";
|
import { Buildings, MapPin, House } from '@/lib/icons';
|
||||||
|
|
||||||
import { DATABASE_ID, TABLES, type Presentation, type Property } from "@/lib/appwrite/schema";
|
import { DATABASE_ID, TABLES, type Presentation, type Property } from "@/lib/appwrite/schema";
|
||||||
import { createAdminClient } from "@/lib/appwrite/server";
|
import { createAdminClient } from "@/lib/appwrite/server";
|
||||||
@@ -35,7 +35,7 @@ export default async function SunumPage({ params }: Props) {
|
|||||||
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
||||||
<div className="text-center px-6">
|
<div className="text-center px-6">
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-orange-100 mb-4">
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-orange-100 mb-4">
|
||||||
<Building2 className="size-8 text-orange-500" />
|
<Buildings className="size-8 text-orange-500" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Sunum Süresi Doldu</h1>
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">Sunum Süresi Doldu</h1>
|
||||||
<p className="text-gray-500">Bu sunum bağlantısı artık geçerli değil.</p>
|
<p className="text-gray-500">Bu sunum bağlantısı artık geçerli değil.</p>
|
||||||
@@ -76,7 +76,7 @@ export default async function SunumPage({ params }: Props) {
|
|||||||
<header className="bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white">
|
<header className="bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white">
|
||||||
<div className="max-w-5xl mx-auto px-6 py-10">
|
<div className="max-w-5xl mx-auto px-6 py-10">
|
||||||
<div className="flex items-center gap-2 text-slate-400 text-sm mb-5">
|
<div className="flex items-center gap-2 text-slate-400 text-sm mb-5">
|
||||||
<Building2 className="size-4" />
|
<Buildings className="size-4" />
|
||||||
<span>Emlak Sunumu</span>
|
<span>Emlak Sunumu</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">{presentation.title}</h1>
|
<h1 className="text-3xl font-bold tracking-tight">{presentation.title}</h1>
|
||||||
@@ -108,7 +108,7 @@ export default async function SunumPage({ params }: Props) {
|
|||||||
))}
|
))}
|
||||||
{properties.length === 0 && (
|
{properties.length === 0 && (
|
||||||
<div className="col-span-full text-center py-20">
|
<div className="col-span-full text-center py-20">
|
||||||
<Home className="size-10 text-gray-200 mx-auto mb-3" />
|
<House className="size-10 text-gray-200 mx-auto mb-3" />
|
||||||
<p className="text-gray-400">Bu sunumda ilan bulunmuyor.</p>
|
<p className="text-gray-400">Bu sunumda ilan bulunmuyor.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -159,7 +159,7 @@ function PropertyCard({ property: p }: { property: Property }) {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<Home className="size-12 text-slate-200" />
|
<House className="size-12 text-slate-200" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { CheckCircle, Circle } from '@/lib/icons';
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ACADEMY_MODULES } from "@/lib/academy/tours";
|
||||||
|
import { getCompletedModules, resetProgress } from "@/lib/academy/progress";
|
||||||
|
import { AcademyTourButton } from "./academy-tour-button";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function AcademyClient() {
|
||||||
|
const [completed, setCompleted] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCompleted(getCompletedModules());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleComplete() {
|
||||||
|
setCompleted(getCompletedModules());
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
resetProgress();
|
||||||
|
setCompleted([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const percent = Math.round((completed.length / ACADEMY_MODULES.length) * 100);
|
||||||
|
const allDone = completed.length === ACADEMY_MODULES.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Progress header */}
|
||||||
|
<div className="bg-card border rounded-xl p-5 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-sm">Genel İlerleme</p>
|
||||||
|
<p className="text-muted-foreground text-xs mt-0.5">
|
||||||
|
{completed.length} / {ACADEMY_MODULES.length} modül tamamlandı
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={cn(
|
||||||
|
"text-2xl font-bold",
|
||||||
|
allDone ? "text-green-600" : "text-primary"
|
||||||
|
)}>
|
||||||
|
%{percent}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full rounded-full transition-all duration-500",
|
||||||
|
allDone ? "bg-green-500" : "bg-primary"
|
||||||
|
)}
|
||||||
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{allDone && (
|
||||||
|
<p className="text-green-600 text-sm font-medium">
|
||||||
|
🎉 Tüm modülleri tamamladınız! Artık KovakEmlak CRM'i tam verimle kullanabilirsiniz.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Module grid */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{ACADEMY_MODULES.map((mod) => {
|
||||||
|
const isDone = completed.includes(mod.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={mod.id}
|
||||||
|
className={cn(
|
||||||
|
"bg-card border rounded-xl p-5 flex flex-col gap-3 transition-colors",
|
||||||
|
isDone && "border-green-200 bg-green-50/40 dark:bg-green-950/20 dark:border-green-900"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className="text-2xl">{mod.icon}</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-sm leading-tight">{mod.title}</p>
|
||||||
|
<p className="text-muted-foreground text-xs mt-0.5">
|
||||||
|
{mod.steps.length} adım
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isDone ? (
|
||||||
|
<CheckCircle className="size-5 text-green-500 shrink-0 mt-0.5" />
|
||||||
|
) : (
|
||||||
|
<Circle className="size-5 text-muted-foreground/40 shrink-0 mt-0.5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-xs leading-relaxed flex-1">
|
||||||
|
{mod.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Step previews */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{mod.steps.slice(0, 3).map((step, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className={cn(
|
||||||
|
"size-4 rounded-full flex items-center justify-center text-[10px] font-medium shrink-0",
|
||||||
|
isDone
|
||||||
|
? "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{step.title}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AcademyTourButton
|
||||||
|
module={mod}
|
||||||
|
onComplete={handleComplete}
|
||||||
|
variant={isDone ? "ghost" : "outline"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{completed.length > 0 && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleReset} className="text-muted-foreground text-xs">
|
||||||
|
İlerlemeyi sıfırla
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { GraduationCap } from '@/lib/icons';
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ACADEMY_MODULES } from "@/lib/academy/tours";
|
||||||
|
import { getCompletedModules } from "@/lib/academy/progress";
|
||||||
|
|
||||||
|
export function AcademySidebarBadge() {
|
||||||
|
const [percent, setPercent] = useState(0);
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isActive = pathname === "/academy";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function update() {
|
||||||
|
const completed = getCompletedModules();
|
||||||
|
setPercent(Math.round((completed.length / ACADEMY_MODULES.length) * 100));
|
||||||
|
}
|
||||||
|
update();
|
||||||
|
window.addEventListener("focus", update);
|
||||||
|
return () => window.removeEventListener("focus", update);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (percent === 100) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href="/academy"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-colors mx-2",
|
||||||
|
isActive
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<GraduationCap className="size-4 shrink-0" />
|
||||||
|
<span className="flex-1 text-xs font-medium">Akademi</span>
|
||||||
|
<span className={cn(
|
||||||
|
"text-[10px] font-semibold px-1.5 py-0.5 rounded-full",
|
||||||
|
percent === 0
|
||||||
|
? "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300"
|
||||||
|
: "bg-primary/15 text-primary"
|
||||||
|
)}>
|
||||||
|
%{percent}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { PlayCircle } from '@/lib/icons';
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { AcademyModule } from "@/lib/academy/tours";
|
||||||
|
import { markModuleComplete } from "@/lib/academy/progress";
|
||||||
|
|
||||||
|
interface AcademyTourButtonProps {
|
||||||
|
module: AcademyModule;
|
||||||
|
onComplete?: () => void;
|
||||||
|
variant?: "default" | "outline" | "ghost";
|
||||||
|
size?: "default" | "sm";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AcademyTourButton({
|
||||||
|
module,
|
||||||
|
onComplete,
|
||||||
|
variant = "outline",
|
||||||
|
size = "sm",
|
||||||
|
}: AcademyTourButtonProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
async function startTour() {
|
||||||
|
// @ts-expect-error - CSS loaded dynamically
|
||||||
|
await import("driver.js/dist/driver.css");
|
||||||
|
const { driver } = await import("driver.js");
|
||||||
|
|
||||||
|
router.push(module.url);
|
||||||
|
await new Promise((r) => setTimeout(r, 900));
|
||||||
|
|
||||||
|
let driverObj: ReturnType<typeof driver>;
|
||||||
|
|
||||||
|
driverObj = driver({
|
||||||
|
showProgress: true,
|
||||||
|
progressText: "{{current}} / {{total}}",
|
||||||
|
nextBtnText: "İleri →",
|
||||||
|
prevBtnText: "← Geri",
|
||||||
|
doneBtnText: "Tamamla ✓",
|
||||||
|
smoothScroll: true,
|
||||||
|
onDestroyStarted: () => {
|
||||||
|
driverObj.destroy();
|
||||||
|
},
|
||||||
|
onDestroyed: () => {
|
||||||
|
// Close any open form
|
||||||
|
if (module.closeEvent) {
|
||||||
|
window.dispatchEvent(new CustomEvent(module.closeEvent));
|
||||||
|
}
|
||||||
|
markModuleComplete(module.id);
|
||||||
|
onComplete?.();
|
||||||
|
},
|
||||||
|
onNextClick: (_el, _step, { state }) => {
|
||||||
|
const nextIndex = (state.activeIndex ?? 0) + 1;
|
||||||
|
if (nextIndex >= module.steps.length) {
|
||||||
|
driverObj.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStep = module.steps[nextIndex];
|
||||||
|
|
||||||
|
// Dispatch a window event before this step (e.g. open a form)
|
||||||
|
if (nextStep.triggerEvent) {
|
||||||
|
window.dispatchEvent(new CustomEvent(nextStep.triggerEvent));
|
||||||
|
setTimeout(() => driverObj.moveNext(), nextStep.triggerDelay ?? 700);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click a DOM element before this step (e.g. navigate form wizard)
|
||||||
|
if (nextStep.clickBefore) {
|
||||||
|
const target = document.querySelector<HTMLElement>(nextStep.clickBefore);
|
||||||
|
target?.click();
|
||||||
|
setTimeout(() => driverObj.moveNext(), nextStep.clickDelay ?? 350);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
driverObj.moveNext();
|
||||||
|
},
|
||||||
|
steps: module.steps.map((s) => ({
|
||||||
|
element: s.element,
|
||||||
|
popover: {
|
||||||
|
title: s.title,
|
||||||
|
description: s.description,
|
||||||
|
side: s.side ?? "bottom",
|
||||||
|
align: "start",
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
driverObj.drive();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant={variant} size={size} onClick={startTour} className="gap-1.5">
|
||||||
|
<PlayCircle className="size-4" />
|
||||||
|
Turu Başlat
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { MoreHorizontal, Plus, Pencil, Trash2, CheckCircle } from "lucide-react";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { DotsThree, Plus, PencilSimple, Trash, CheckCircle, List, CalendarDots } from '@/lib/icons';
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -17,9 +18,13 @@ import {
|
|||||||
deleteActivityAction,
|
deleteActivityAction,
|
||||||
} from "@/lib/appwrite/activity-actions";
|
} from "@/lib/appwrite/activity-actions";
|
||||||
import { ActivityFormSheet } from "./activity-form-sheet";
|
import { ActivityFormSheet } from "./activity-form-sheet";
|
||||||
|
import { ActivityCalendar } from "./activity-calendar";
|
||||||
|
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||||
import type { Activity, Customer, Property } from "@/lib/appwrite/schema";
|
import type { Activity, Customer, Property } from "@/lib/appwrite/schema";
|
||||||
import { ACTIVITY_TYPE_LABELS } from "@/lib/appwrite/schema";
|
import { ACTIVITY_TYPE_LABELS } from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
|
type ViewMode = "list" | "calendar";
|
||||||
|
|
||||||
interface ActivitiesClientProps {
|
interface ActivitiesClientProps {
|
||||||
initialActivities: Activity[];
|
initialActivities: Activity[];
|
||||||
customers: Customer[];
|
customers: Customer[];
|
||||||
@@ -31,9 +36,12 @@ export function ActivitiesClient({
|
|||||||
customers,
|
customers,
|
||||||
properties,
|
properties,
|
||||||
}: ActivitiesClientProps) {
|
}: ActivitiesClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
const [activities, setActivities] = useState(initialActivities);
|
const [activities, setActivities] = useState(initialActivities);
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Activity | null>(null);
|
const [editing, setEditing] = useState<Activity | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Activity | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
||||||
|
|
||||||
function customerName(id?: string | null) {
|
function customerName(id?: string | null) {
|
||||||
if (!id) return "—";
|
if (!id) return "—";
|
||||||
@@ -45,15 +53,19 @@ export function ActivitiesClient({
|
|||||||
return properties.find((p) => p.$id === id)?.title ?? "—";
|
return properties.find((p) => p.$id === id)?.title ?? "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
useEffect(() => {
|
||||||
setEditing(null);
|
const open = () => { setEditing(null); setSheetOpen(true); };
|
||||||
setSheetOpen(true);
|
const close = () => setSheetOpen(false);
|
||||||
}
|
window.addEventListener("kovak:open-form-activities", open);
|
||||||
|
window.addEventListener("kovak:close-form-activities", close);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("kovak:open-form-activities", open);
|
||||||
|
window.removeEventListener("kovak:close-form-activities", close);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
function openEdit(a: Activity) {
|
function openCreate() { setEditing(null); setSheetOpen(true); }
|
||||||
setEditing(a);
|
function openEdit(a: Activity) { setEditing(a); setSheetOpen(true); }
|
||||||
setSheetOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleComplete(a: Activity) {
|
async function handleComplete(a: Activity) {
|
||||||
const result = await completeActivityAction(a.$id);
|
const result = await completeActivityAction(a.$id);
|
||||||
@@ -67,11 +79,12 @@ export function ActivitiesClient({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(a: Activity) {
|
async function doDelete() {
|
||||||
if (!confirm("Bu aktivite silinsin mi?")) return;
|
if (!deleteTarget) return;
|
||||||
const result = await deleteActivityAction(a.$id);
|
const result = await deleteActivityAction(deleteTarget.$id);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
setActivities((prev) => prev.filter((x) => x.$id !== a.$id));
|
setActivities((prev) => prev.filter((x) => x.$id !== deleteTarget.$id));
|
||||||
|
setDeleteTarget(null);
|
||||||
toast.success("Aktivite silindi.");
|
toast.success("Aktivite silindi.");
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error ?? "Silinemedi.");
|
toast.error(result.error ?? "Silinemedi.");
|
||||||
@@ -80,15 +93,57 @@ export function ActivitiesClient({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
<h1 className="text-2xl font-bold">Aktiviteler</h1>
|
<h1 className="text-2xl font-bold">Aktiviteler</h1>
|
||||||
<Button onClick={openCreate} size="sm">
|
<div className="flex items-center gap-2">
|
||||||
<Plus className="mr-1.5 size-4" />
|
{/* View toggle */}
|
||||||
Yeni Aktivite
|
<div className="flex rounded-md border overflow-hidden">
|
||||||
</Button>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setViewMode("list")}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||||
|
viewMode === "list"
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-background text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<List className="size-3.5" />
|
||||||
|
Liste
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setViewMode("calendar")}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||||
|
viewMode === "calendar"
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-background text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CalendarDots className="size-3.5" />
|
||||||
|
Takvim
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreate} size="sm" data-tour="activities-add">
|
||||||
|
<Plus className="mr-1.5 size-4" />
|
||||||
|
Yeni Aktivite
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border">
|
{/* Calendar view */}
|
||||||
|
{viewMode === "calendar" && (
|
||||||
|
<ActivityCalendar
|
||||||
|
activities={activities}
|
||||||
|
customers={customers}
|
||||||
|
properties={properties}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onComplete={handleComplete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* List view */}
|
||||||
|
{viewMode === "list" && (
|
||||||
|
<div data-tour="activities-table" className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -133,7 +188,7 @@ export function ActivitiesClient({
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="size-8">
|
<Button variant="ghost" size="icon" className="size-8">
|
||||||
<MoreHorizontal className="size-4" />
|
<DotsThree className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
@@ -144,14 +199,14 @@ export function ActivitiesClient({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem onClick={() => openEdit(a)}>
|
<DropdownMenuItem onClick={() => openEdit(a)}>
|
||||||
<Pencil className="mr-2 size-4" />
|
<PencilSimple className="mr-2 size-4" />
|
||||||
Düzenle
|
Düzenle
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleDelete(a)}
|
onClick={() => setDeleteTarget(a)}
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 size-4" />
|
<Trash className="mr-2 size-4" />
|
||||||
Sil
|
Sil
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -162,6 +217,7 @@ export function ActivitiesClient({
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ActivityFormSheet
|
<ActivityFormSheet
|
||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
@@ -169,6 +225,14 @@ export function ActivitiesClient({
|
|||||||
activity={editing}
|
activity={editing}
|
||||||
customers={customers}
|
customers={customers}
|
||||||
properties={properties}
|
properties={properties}
|
||||||
|
onSuccess={() => router.refresh()}
|
||||||
|
/>
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(v) => { if (!v) setDeleteTarget(null); }}
|
||||||
|
title="Bu aktivite silinsin mi?"
|
||||||
|
description="Bu aktivite kalıcı olarak silinecek ve geri alınamaz."
|
||||||
|
onConfirm={doDelete}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import { CaretLeft, CaretRight, CheckCircle, PencilSimple, Clock, User, Buildings } from '@/lib/icons';
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import type { Activity, Customer, Property } from "@/lib/appwrite/schema";
|
||||||
|
import { ACTIVITY_TYPE_LABELS } from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
|
const DAYS = ["Pzt", "Sal", "Çar", "Per", "Cum", "Cmt", "Paz"];
|
||||||
|
const MONTHS = [
|
||||||
|
"Ocak", "Şubat", "Mart", "Nisan", "Mayıs", "Haziran",
|
||||||
|
"Temmuz", "Ağustos", "Eylül", "Ekim", "Kasım", "Aralık",
|
||||||
|
];
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
|
gorusme: "bg-blue-500",
|
||||||
|
teklif: "bg-amber-500",
|
||||||
|
ziyaret: "bg-emerald-500",
|
||||||
|
arama: "bg-purple-500",
|
||||||
|
not: "bg-gray-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_BADGE_COLORS: Record<string, string> = {
|
||||||
|
gorusme: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300",
|
||||||
|
teklif: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
|
||||||
|
ziyaret: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
|
||||||
|
arama: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300",
|
||||||
|
not: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
function toDateKey(date: Date): string {
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activityDateKey(a: Activity): string | null {
|
||||||
|
if (!a.dueDate) return null;
|
||||||
|
const d = new Date(a.dueDate);
|
||||||
|
if (isNaN(d.getTime())) return null;
|
||||||
|
return toDateKey(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
activities: Activity[];
|
||||||
|
customers: Customer[];
|
||||||
|
properties: Property[];
|
||||||
|
onEdit: (a: Activity) => void;
|
||||||
|
onComplete: (a: Activity) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityCalendar({ activities, customers, properties, onEdit, onComplete }: Props) {
|
||||||
|
const today = new Date();
|
||||||
|
const [year, setYear] = useState(today.getFullYear());
|
||||||
|
const [month, setMonth] = useState(today.getMonth());
|
||||||
|
const [selectedKey, setSelectedKey] = useState<string | null>(toDateKey(today));
|
||||||
|
|
||||||
|
function prevMonth() {
|
||||||
|
if (month === 0) { setMonth(11); setYear(y => y - 1); }
|
||||||
|
else setMonth(m => m - 1);
|
||||||
|
}
|
||||||
|
function nextMonth() {
|
||||||
|
if (month === 11) { setMonth(0); setYear(y => y + 1); }
|
||||||
|
else setMonth(m => m + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index activities by date key
|
||||||
|
const byDate = useMemo(() => {
|
||||||
|
const map: Record<string, Activity[]> = {};
|
||||||
|
for (const a of activities) {
|
||||||
|
const key = activityDateKey(a);
|
||||||
|
if (!key) continue;
|
||||||
|
if (!map[key]) map[key] = [];
|
||||||
|
map[key].push(a);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [activities]);
|
||||||
|
|
||||||
|
// Build calendar grid
|
||||||
|
const cells = useMemo(() => {
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
// Monday-based: 0=Mon … 6=Sun
|
||||||
|
let startOffset = firstDay.getDay() - 1;
|
||||||
|
if (startOffset < 0) startOffset = 6;
|
||||||
|
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
const totalCells = Math.ceil((startOffset + daysInMonth) / 7) * 7;
|
||||||
|
|
||||||
|
const result: Array<{ date: Date | null; key: string | null }> = [];
|
||||||
|
for (let i = 0; i < totalCells; i++) {
|
||||||
|
const dayNum = i - startOffset + 1;
|
||||||
|
if (dayNum < 1 || dayNum > daysInMonth) {
|
||||||
|
result.push({ date: null, key: null });
|
||||||
|
} else {
|
||||||
|
const d = new Date(year, month, dayNum);
|
||||||
|
result.push({ date: d, key: toDateKey(d) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [year, month]);
|
||||||
|
|
||||||
|
const selectedActivities = selectedKey ? (byDate[selectedKey] ?? []) : [];
|
||||||
|
const selectedDate = selectedKey ? new Date(selectedKey + "T12:00:00") : null;
|
||||||
|
|
||||||
|
function customerName(id?: string | null) {
|
||||||
|
if (!id) return null;
|
||||||
|
return customers.find((c) => c.$id === id)?.name ?? null;
|
||||||
|
}
|
||||||
|
function propertyTitle(id?: string | null) {
|
||||||
|
if (!id) return null;
|
||||||
|
return properties.find((p) => p.$id === id)?.title ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const todayKey = toDateKey(today);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4 min-h-0">
|
||||||
|
{/* === Calendar Grid === */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Month nav */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<Button variant="ghost" size="icon" onClick={prevMonth} className="size-8">
|
||||||
|
<CaretLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="font-semibold text-base">
|
||||||
|
{MONTHS[month]} {year}
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" size="icon" onClick={nextMonth} className="size-8">
|
||||||
|
<CaretRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day headers */}
|
||||||
|
<div className="grid grid-cols-7 mb-1">
|
||||||
|
{DAYS.map((d) => (
|
||||||
|
<div key={d} className="text-center text-xs text-muted-foreground py-1 font-medium">
|
||||||
|
{d}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day cells */}
|
||||||
|
<div className="grid grid-cols-7 gap-px bg-border rounded-lg overflow-hidden border">
|
||||||
|
{cells.map((cell, i) => {
|
||||||
|
if (!cell.date || !cell.key) {
|
||||||
|
return <div key={i} className="bg-muted/40 min-h-[72px]" />;
|
||||||
|
}
|
||||||
|
const cellActivities = byDate[cell.key] ?? [];
|
||||||
|
const isToday = cell.key === todayKey;
|
||||||
|
const isSelected = cell.key === selectedKey;
|
||||||
|
const visible = cellActivities.slice(0, 3);
|
||||||
|
const overflow = cellActivities.length - 3;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cell.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedKey(cell.key)}
|
||||||
|
className={`bg-background min-h-[72px] p-1.5 text-left transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
||||||
|
isSelected ? "ring-2 ring-inset ring-primary" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium inline-flex size-5 items-center justify-center rounded-full mb-1 ${
|
||||||
|
isToday
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cell.date.getDate()}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-col gap-px">
|
||||||
|
{visible.map((a) => (
|
||||||
|
<div
|
||||||
|
key={a.$id}
|
||||||
|
className={`text-[10px] leading-4 rounded px-1 truncate text-white ${TYPE_COLORS[a.type] ?? "bg-gray-400"} ${
|
||||||
|
a.completedAt ? "opacity-50" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{a.title}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{overflow > 0 && (
|
||||||
|
<div className="text-[10px] text-muted-foreground pl-1">+{overflow} daha</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex flex-wrap gap-3 mt-3 text-xs text-muted-foreground">
|
||||||
|
{Object.entries(ACTIVITY_TYPE_LABELS).map(([key, label]) => (
|
||||||
|
<div key={key} className="flex items-center gap-1">
|
||||||
|
<span className={`inline-block size-2 rounded-full ${TYPE_COLORS[key]}`} />
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* === Day Panel === */}
|
||||||
|
<div className="w-full lg:w-72 xl:w-80 shrink-0">
|
||||||
|
<div className="rounded-lg border h-full flex flex-col">
|
||||||
|
{/* Panel header */}
|
||||||
|
<div className="px-4 py-3 border-b">
|
||||||
|
{selectedDate ? (
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-sm">
|
||||||
|
{selectedDate.toLocaleDateString("tr-TR", { weekday: "long", day: "numeric", month: "long" })}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{selectedActivities.length === 0
|
||||||
|
? "Bu gün için aktivite yok"
|
||||||
|
: `${selectedActivities.length} aktivite`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">Gün seçin</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activity list */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{selectedActivities.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground text-sm">
|
||||||
|
<Clock className="size-8 mb-2 opacity-30" />
|
||||||
|
<span>Aktivite bulunmuyor</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{selectedActivities.map((a) => (
|
||||||
|
<ActivityCard
|
||||||
|
key={a.$id}
|
||||||
|
activity={a}
|
||||||
|
customerName={customerName(a.customerId)}
|
||||||
|
propertyTitle={propertyTitle(a.propertyId)}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onComplete={onComplete}
|
||||||
|
typeBadgeColor={TYPE_BADGE_COLORS[a.type] ?? TYPE_BADGE_COLORS.not}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
activity: Activity;
|
||||||
|
customerName: string | null;
|
||||||
|
propertyTitle: string | null;
|
||||||
|
typeBadgeColor: string;
|
||||||
|
onEdit: (a: Activity) => void;
|
||||||
|
onComplete: (a: Activity) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActivityCard({ activity: a, customerName, propertyTitle, typeBadgeColor, onEdit, onComplete }: CardProps) {
|
||||||
|
return (
|
||||||
|
<div className={`px-4 py-3 hover:bg-muted/40 transition-colors ${a.completedAt ? "opacity-60" : ""}`}>
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-1.5">
|
||||||
|
<span className={`inline-flex text-[10px] font-medium px-1.5 py-0.5 rounded-full ${typeBadgeColor}`}>
|
||||||
|
{ACTIVITY_TYPE_LABELS[a.type] ?? a.type}
|
||||||
|
</span>
|
||||||
|
{a.completedAt ? (
|
||||||
|
<Badge variant="secondary" className="text-[10px] h-4 px-1.5">Tamam</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="text-[10px] h-4 px-1.5">Açık</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm font-medium leading-tight mb-1.5">{a.title}</p>
|
||||||
|
|
||||||
|
{a.description && (
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2 mb-1.5">{a.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-0.5 mb-2">
|
||||||
|
{customerName && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<User className="size-3 shrink-0" />
|
||||||
|
<span className="truncate">{customerName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{propertyTitle && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Buildings className="size-3 shrink-0" />
|
||||||
|
<span className="truncate">{propertyTitle}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{!a.completedAt && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-xs px-2 flex-1"
|
||||||
|
onClick={() => onComplete(a)}
|
||||||
|
>
|
||||||
|
<CheckCircle className="size-3 mr-1" />
|
||||||
|
Tamamla
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-xs px-2 flex-1"
|
||||||
|
onClick={() => onEdit(a)}
|
||||||
|
>
|
||||||
|
<PencilSimple className="size-3 mr-1" />
|
||||||
|
Düzenle
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState, useEffect } from "react";
|
import { useActionState, useEffect } from "react";
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import { ResponsiveSheet, FormWizard } from "@/components/ui/responsive-sheet";
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetFooter,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from "@/components/ui/sheet";
|
|
||||||
import { createActivityAction, updateActivityAction } from "@/lib/appwrite/activity-actions";
|
import { createActivityAction, updateActivityAction } from "@/lib/appwrite/activity-actions";
|
||||||
import type { Activity, Customer, Property } from "@/lib/appwrite/schema";
|
import type { Activity, Customer, Property } from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
@@ -30,18 +22,8 @@ interface ActivityFormSheetProps {
|
|||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActivityFormSheet({
|
export function ActivityFormSheet({ open, onOpenChange, activity, customers, properties, onSuccess }: ActivityFormSheetProps) {
|
||||||
open,
|
const action = activity ? updateActivityAction.bind(null, activity.$id) : createActivityAction;
|
||||||
onOpenChange,
|
|
||||||
activity,
|
|
||||||
customers,
|
|
||||||
properties,
|
|
||||||
onSuccess,
|
|
||||||
}: ActivityFormSheetProps) {
|
|
||||||
const action = activity
|
|
||||||
? updateActivityAction.bind(null, activity.$id)
|
|
||||||
: createActivityAction;
|
|
||||||
|
|
||||||
const [state, formAction, isPending] = useActionState(action, INITIAL);
|
const [state, formAction, isPending] = useActionState(action, INITIAL);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -56,86 +38,82 @@ export function ActivityFormSheet({
|
|||||||
|
|
||||||
const fe = state.fieldErrors ?? {};
|
const fe = state.fieldErrors ?? {};
|
||||||
|
|
||||||
return (
|
const steps = [
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
{
|
||||||
<SheetContent className="sm:max-w-md">
|
label: "Aktivite",
|
||||||
<SheetHeader>
|
content: (
|
||||||
<SheetTitle>{activity ? "Aktiviteyi Düzenle" : "Yeni Aktivite"}</SheetTitle>
|
<>
|
||||||
</SheetHeader>
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="grid gap-1.5">
|
||||||
<form action={formAction} className="mt-4 space-y-4 pb-6">
|
<Label>Tip *</Label>
|
||||||
<div className="grid gap-1.5">
|
<select name="type" defaultValue={activity?.type ?? "gorusme"}
|
||||||
<Label>Tip *</Label>
|
className="border-input bg-background h-9 rounded-md border px-3 text-sm">
|
||||||
<select
|
<option value="gorusme">Görüşme</option>
|
||||||
name="type"
|
<option value="teklif">Teklif</option>
|
||||||
defaultValue={activity?.type ?? "gorusme"}
|
<option value="ziyaret">Ziyaret</option>
|
||||||
className="border-input bg-background h-9 rounded-md border px-3 text-sm"
|
<option value="arama">Arama</option>
|
||||||
>
|
<option value="not">Not</option>
|
||||||
<option value="gorusme">Görüşme</option>
|
</select>
|
||||||
<option value="teklif">Teklif</option>
|
</div>
|
||||||
<option value="ziyaret">Ziyaret</option>
|
<div className="col-span-2 grid gap-1.5">
|
||||||
<option value="arama">Arama</option>
|
<Label htmlFor="title">Başlık *</Label>
|
||||||
<option value="not">Not</option>
|
<Input id="title" name="title" defaultValue={activity?.title} placeholder="Görüşme notu..." />
|
||||||
</select>
|
{fe.title && <p className="text-destructive text-xs">{fe.title[0]}</p>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="title">Başlık *</Label>
|
<Label htmlFor="dueDate">Tarih</Label>
|
||||||
<Input id="title" name="title" defaultValue={activity?.title} placeholder="Görüşme notu..." />
|
<Input id="dueDate" name="dueDate" type="date"
|
||||||
{fe.title && <p className="text-destructive text-xs">{fe.title[0]}</p>}
|
defaultValue={activity?.dueDate ? activity.dueDate.split("T")[0] : ""} />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Bağlantı",
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label>Müşteri</Label>
|
<Label>Müşteri</Label>
|
||||||
<select
|
<select name="customerId" defaultValue={activity?.customerId ?? ""}
|
||||||
name="customerId"
|
className="border-input bg-background h-9 rounded-md border px-3 text-sm">
|
||||||
defaultValue={activity?.customerId ?? ""}
|
|
||||||
className="border-input bg-background h-9 rounded-md border px-3 text-sm"
|
|
||||||
>
|
|
||||||
<option value="">Seçiniz</option>
|
<option value="">Seçiniz</option>
|
||||||
{customers.map((c) => (
|
{customers.map((c) => (
|
||||||
<option key={c.$id} value={c.$id}>{c.name}</option>
|
<option key={c.$id} value={c.$id}>{c.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label>İlan</Label>
|
<Label>İlan</Label>
|
||||||
<select
|
<select name="propertyId" defaultValue={activity?.propertyId ?? ""}
|
||||||
name="propertyId"
|
className="border-input bg-background h-9 rounded-md border px-3 text-sm">
|
||||||
defaultValue={activity?.propertyId ?? ""}
|
|
||||||
className="border-input bg-background h-9 rounded-md border px-3 text-sm"
|
|
||||||
>
|
|
||||||
<option value="">Seçiniz</option>
|
<option value="">Seçiniz</option>
|
||||||
{properties.map((p) => (
|
{properties.map((p) => (
|
||||||
<option key={p.$id} value={p.$id}>{p.title}</option>
|
<option key={p.$id} value={p.$id}>{p.title}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Detay",
|
||||||
|
content: (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="description">Açıklama</Label>
|
||||||
|
<Textarea id="description" name="description" rows={5} defaultValue={activity?.description ?? ""} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
<div className="grid gap-1.5">
|
return (
|
||||||
<Label htmlFor="dueDate">Tarih</Label>
|
<ResponsiveSheet open={open} onOpenChange={onOpenChange}
|
||||||
<Input
|
title={activity ? "Aktiviteyi Düzenle" : "Yeni Aktivite"}
|
||||||
id="dueDate"
|
maxWidth="sm:max-w-lg">
|
||||||
name="dueDate"
|
<form action={formAction}>
|
||||||
type="date"
|
<FormWizard steps={steps} isPending={isPending} submitLabel={activity ? "Güncelle" : "Oluştur"} />
|
||||||
defaultValue={activity?.dueDate ? activity.dueDate.split("T")[0] : ""}
|
</form>
|
||||||
/>
|
</ResponsiveSheet>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<Label htmlFor="description">Açıklama</Label>
|
|
||||||
<Textarea id="description" name="description" rows={3} defaultValue={activity?.description ?? ""} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SheetFooter>
|
|
||||||
<Button type="submit" disabled={isPending} className="w-full">
|
|
||||||
{isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
|
|
||||||
{activity ? "Güncelle" : "Oluştur"}
|
|
||||||
</Button>
|
|
||||||
</SheetFooter>
|
|
||||||
</form>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,24 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
Activity,
|
ClipboardText,
|
||||||
Building2,
|
Buildings,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
FileText,
|
FileText,
|
||||||
LayoutDashboard,
|
SquaresFour,
|
||||||
Presentation,
|
Presentation,
|
||||||
Search,
|
MagnifyingGlass,
|
||||||
Settings,
|
GearSix,
|
||||||
|
TrendUp,
|
||||||
Users,
|
Users,
|
||||||
Wallet,
|
Wallet,
|
||||||
} from "lucide-react";
|
} from '@/lib/icons';
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { Logo } from "@/components/logo";
|
import { Logo } from "@/components/logo";
|
||||||
import { NavMain } from "@/components/nav-main";
|
import { NavMain } from "@/components/nav-main";
|
||||||
import { NavUser } from "@/components/nav-user";
|
import { NavUser } from "@/components/nav-user";
|
||||||
|
import { AcademySidebarBadge } from "@/components/academy/academy-sidebar-badge";
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -37,7 +39,7 @@ const navGroups = [
|
|||||||
{
|
{
|
||||||
title: "Genel Bakış",
|
title: "Genel Bakış",
|
||||||
url: "/dashboard",
|
url: "/dashboard",
|
||||||
icon: LayoutDashboard,
|
icon: SquaresFour,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -47,7 +49,7 @@ const navGroups = [
|
|||||||
{
|
{
|
||||||
title: "İlanlar",
|
title: "İlanlar",
|
||||||
url: "/properties",
|
url: "/properties",
|
||||||
icon: Building2,
|
icon: Buildings,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -82,7 +84,12 @@ const navGroups = [
|
|||||||
{
|
{
|
||||||
title: "Aktiviteler",
|
title: "Aktiviteler",
|
||||||
url: "/activities",
|
url: "/activities",
|
||||||
icon: Activity,
|
icon: ClipboardText,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Finans",
|
||||||
|
url: "/finance",
|
||||||
|
icon: TrendUp,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -92,7 +99,7 @@ const navGroups = [
|
|||||||
{
|
{
|
||||||
title: "Çalışma Alanı",
|
title: "Çalışma Alanı",
|
||||||
url: "/settings/workspace",
|
url: "/settings/workspace",
|
||||||
icon: Settings,
|
icon: GearSix,
|
||||||
items: [
|
items: [
|
||||||
{ title: "Ofis Bilgileri", url: "/settings/workspace" },
|
{ title: "Ofis Bilgileri", url: "/settings/workspace" },
|
||||||
{ title: "Ekip Üyeleri", url: "/settings/members" },
|
{ title: "Ekip Üyeleri", url: "/settings/members" },
|
||||||
@@ -115,11 +122,26 @@ const navGroups = [
|
|||||||
export function AppSidebar({
|
export function AppSidebar({
|
||||||
user,
|
user,
|
||||||
company,
|
company,
|
||||||
|
pendingMatchCount = 0,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Sidebar> & {
|
}: React.ComponentProps<typeof Sidebar> & {
|
||||||
user: ShellUser;
|
user: ShellUser;
|
||||||
company: ShellCompany;
|
company: ShellCompany;
|
||||||
|
pendingMatchCount?: number;
|
||||||
}) {
|
}) {
|
||||||
|
// Inject badge into the Eşleşmeler sub-item
|
||||||
|
const groups = navGroups.map((group) => ({
|
||||||
|
...group,
|
||||||
|
items: group.items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
items: item.items?.map((sub) =>
|
||||||
|
sub.url === "/customers/matches" && pendingMatchCount > 0
|
||||||
|
? { ...sub, badge: pendingMatchCount }
|
||||||
|
: sub,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar {...props}>
|
<Sidebar {...props}>
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
@@ -151,11 +173,12 @@ export function AppSidebar({
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
{navGroups.map((group) => (
|
{groups.map((group) => (
|
||||||
<NavMain key={group.label} label={group.label} items={group.items} />
|
<NavMain key={group.label} label={group.label} items={group.items} />
|
||||||
))}
|
))}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
|
<AcademySidebarBadge />
|
||||||
<NavUser user={user} />
|
<NavUser user={user} />
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Crown } from "lucide-react";
|
import { Crown } from '@/lib/icons';
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ import * as React from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Command as CommandPrimitive } from "cmdk";
|
import { Command as CommandPrimitive } from "cmdk";
|
||||||
import {
|
import {
|
||||||
Activity,
|
ClipboardText,
|
||||||
Building2,
|
Buildings,
|
||||||
LayoutDashboard,
|
SquaresFour,
|
||||||
Presentation,
|
Presentation,
|
||||||
Search,
|
MagnifyingGlass,
|
||||||
Settings,
|
GearSix,
|
||||||
Users,
|
Users,
|
||||||
Wallet,
|
Wallet,
|
||||||
type LucideIcon,
|
type Icon,
|
||||||
} from "lucide-react";
|
} from '@/lib/icons';
|
||||||
|
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -33,16 +33,16 @@ const Command = React.forwardRef<
|
|||||||
));
|
));
|
||||||
Command.displayName = CommandPrimitive.displayName;
|
Command.displayName = CommandPrimitive.displayName;
|
||||||
|
|
||||||
type NavItem = { title: string; url: string; icon: LucideIcon };
|
type NavItem = { title: string; url: string; icon: Icon };
|
||||||
|
|
||||||
const NAV_ITEMS: NavItem[] = [
|
const NAV_ITEMS: NavItem[] = [
|
||||||
{ title: "Genel Bakış", url: "/dashboard", icon: LayoutDashboard },
|
{ title: "Genel Bakış", url: "/dashboard", icon: SquaresFour },
|
||||||
{ title: "İlanlar", url: "/properties", icon: Building2 },
|
{ title: "İlanlar", url: "/properties", icon: Buildings },
|
||||||
{ title: "Müşteriler", url: "/customers", icon: Users },
|
{ title: "Müşteriler", url: "/customers", icon: Users },
|
||||||
{ title: "Yatırımcılar", url: "/investors", icon: Wallet },
|
{ title: "Yatırımcılar", url: "/investors", icon: Wallet },
|
||||||
{ title: "Sunumlar", url: "/presentations", icon: Presentation },
|
{ title: "Sunumlar", url: "/presentations", icon: Presentation },
|
||||||
{ title: "Aktiviteler", url: "/activities", icon: Activity },
|
{ title: "Aktiviteler", url: "/activities", icon: ClipboardText },
|
||||||
{ title: "Ofis Ayarları", url: "/settings/workspace", icon: Settings },
|
{ title: "Ofis Ayarları", url: "/settings/workspace", icon: GearSix },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function CommandSearch() {
|
export function CommandSearch() {
|
||||||
@@ -71,7 +71,7 @@ export function CommandSearch() {
|
|||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
className="bg-muted text-muted-foreground flex items-center gap-2 rounded-md px-3 py-1.5 text-sm"
|
className="bg-muted text-muted-foreground flex items-center gap-2 rounded-md px-3 py-1.5 text-sm"
|
||||||
>
|
>
|
||||||
<Search className="size-3.5" />
|
<MagnifyingGlass className="size-3.5" />
|
||||||
<span>Ara...</span>
|
<span>Ara...</span>
|
||||||
<kbd className="bg-background rounded border px-1 text-xs">⌘K</kbd>
|
<kbd className="bg-background rounded border px-1 text-xs">⌘K</kbd>
|
||||||
</button>
|
</button>
|
||||||
@@ -81,7 +81,7 @@ export function CommandSearch() {
|
|||||||
<DialogTitle className="sr-only">Arama</DialogTitle>
|
<DialogTitle className="sr-only">Arama</DialogTitle>
|
||||||
<Command>
|
<Command>
|
||||||
<div className="flex items-center border-b px-3">
|
<div className="flex items-center border-b px-3">
|
||||||
<Search className="mr-2 size-4 shrink-0 opacity-50" />
|
<MagnifyingGlass className="mr-2 size-4 shrink-0 opacity-50" />
|
||||||
<input
|
<input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
|||||||
@@ -1,22 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState, useEffect } from "react";
|
import { useActionState, useEffect } from "react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { CircleNotch } from '@/lib/icons';
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import { ResponsiveSheet, FormWizard } from "@/components/ui/responsive-sheet";
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetFooter,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from "@/components/ui/sheet";
|
|
||||||
import { createCustomerAction, updateCustomerAction } from "@/lib/appwrite/customer-actions";
|
import { createCustomerAction, updateCustomerAction } from "@/lib/appwrite/customer-actions";
|
||||||
import type { Customer } from "@/lib/appwrite/schema";
|
import type { Customer } from "@/lib/appwrite/schema";
|
||||||
|
import { CUSTOMER_STAGE_LABELS, CUSTOMER_SOURCE_LABELS } from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
type ActionState = { ok: boolean; error?: string; fieldErrors?: Record<string, string[]> };
|
type ActionState = { ok: boolean; error?: string; fieldErrors?: Record<string, string[]> };
|
||||||
const INITIAL: ActionState = { ok: false };
|
const INITIAL: ActionState = { ok: false };
|
||||||
@@ -47,55 +41,103 @@ export function CustomerFormSheet({ open, onOpenChange, customer, onSuccess }: C
|
|||||||
|
|
||||||
const fe = state.fieldErrors ?? {};
|
const fe = state.fieldErrors ?? {};
|
||||||
|
|
||||||
return (
|
const steps = [
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
{
|
||||||
<SheetContent className="sm:max-w-md">
|
label: "Kimlik",
|
||||||
<SheetHeader>
|
content: (
|
||||||
<SheetTitle>{customer ? "Müşteriyi Düzenle" : "Yeni Müşteri"}</SheetTitle>
|
<>
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
<form action={formAction} className="mt-4 space-y-4 pb-6">
|
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="name">Ad Soyad *</Label>
|
<Label htmlFor="name">Ad Soyad *</Label>
|
||||||
<Input id="name" name="name" defaultValue={customer?.name} placeholder="Ahmet Yılmaz" />
|
<Input id="name" name="name" defaultValue={customer?.name} placeholder="Ahmet Yılmaz" />
|
||||||
{fe.name && <p className="text-destructive text-xs">{fe.name[0]}</p>}
|
{fe.name && <p className="text-destructive text-xs">{fe.name[0]}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label>Müşteri tipi *</Label>
|
<Label>Müşteri tipi *</Label>
|
||||||
<select name="type" defaultValue={customer?.type ?? "alici"}
|
<select name="type" defaultValue={customer?.type ?? "alici"}
|
||||||
className="border-input bg-background h-9 rounded-md border px-3 text-sm">
|
className="border-input bg-background h-9 rounded-md border px-3 text-sm">
|
||||||
<option value="alici">Alıcı</option>
|
<option value="alici">Alıcı</option>
|
||||||
<option value="kiraci">Kiracı</option>
|
<option value="kiraci">Kiracı</option>
|
||||||
<option value="yatirimci">Yatırımcı</option>
|
<option value="yatirimci">Yatırımcı</option>
|
||||||
</select>
|
</select>
|
||||||
{fe.type && <p className="text-destructive text-xs">{fe.type[0]}</p>}
|
{fe.type && <p className="text-destructive text-xs">{fe.type[0]}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label>Aşama</Label>
|
||||||
|
<select name="stage" defaultValue={customer?.stage ?? "ilk_temas"}
|
||||||
|
className="border-input bg-background h-9 rounded-md border px-3 text-sm">
|
||||||
|
{Object.entries(CUSTOMER_STAGE_LABELS).map(([v, l]) => (
|
||||||
|
<option key={v} value={v}>{l}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
<div className="grid gap-1.5">
|
),
|
||||||
<Label htmlFor="phone">Telefon</Label>
|
},
|
||||||
<Input id="phone" name="phone" type="tel" defaultValue={customer?.phone ?? ""} placeholder="+90 555 123 45 67" />
|
{
|
||||||
|
label: "İletişim",
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="phone">Telefon</Label>
|
||||||
|
<Input id="phone" name="phone" type="tel" defaultValue={customer?.phone ?? ""} placeholder="+90 555 123 45 67" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input id="email" name="email" type="email" defaultValue={customer?.email ?? ""} placeholder="ahmet@example.com" />
|
||||||
|
{fe.email && <p className="text-destructive text-xs">{fe.email[0]}</p>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label>Kaynak</Label>
|
||||||
<Input id="email" name="email" type="email" defaultValue={customer?.email ?? ""} placeholder="ahmet@example.com" />
|
<select name="source" defaultValue={customer?.source ?? ""}
|
||||||
{fe.email && <p className="text-destructive text-xs">{fe.email[0]}</p>}
|
className="border-input bg-background h-9 rounded-md border px-3 text-sm">
|
||||||
|
<option value="">Seçiniz</option>
|
||||||
|
{Object.entries(CUSTOMER_SOURCE_LABELS).map(([v, l]) => (
|
||||||
|
<option key={v} value={v}>{l}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="nextFollowUpDate">Takip tarihi</Label>
|
||||||
|
<Input id="nextFollowUpDate" name="nextFollowUpDate" type="date"
|
||||||
|
defaultValue={customer?.nextFollowUpDate ? customer.nextFollowUpDate.split("T")[0] : ""} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Not",
|
||||||
|
content: (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="notes">Notlar</Label>
|
||||||
|
<Textarea id="notes" name="notes" rows={4} defaultValue={customer?.notes ?? ""} placeholder="Müşteri hakkında notlar..." />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
<div className="grid gap-1.5">
|
return (
|
||||||
<Label htmlFor="notes">Notlar</Label>
|
<ResponsiveSheet
|
||||||
<Textarea id="notes" name="notes" rows={3} defaultValue={customer?.notes ?? ""} placeholder="Müşteri hakkında notlar..." />
|
open={open}
|
||||||
</div>
|
onOpenChange={onOpenChange}
|
||||||
|
title={customer ? "Müşteriyi Düzenle" : "Yeni Müşteri"}
|
||||||
<SheetFooter>
|
maxWidth="sm:max-w-lg"
|
||||||
<Button type="submit" disabled={isPending} className="w-full">
|
>
|
||||||
{isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
|
<form
|
||||||
{customer ? "Güncelle" : "Oluştur"}
|
action={formAction}
|
||||||
</Button>
|
onKeyDown={(e) => {
|
||||||
</SheetFooter>
|
if (e.key === "Enter" && (e.target as HTMLElement).tagName !== "TEXTAREA") {
|
||||||
</form>
|
e.preventDefault();
|
||||||
</SheetContent>
|
}
|
||||||
</Sheet>
|
}}
|
||||||
|
>
|
||||||
|
<FormWizard steps={steps} isPending={isPending} submitLabel={customer ? "Güncelle" : "Oluştur"} />
|
||||||
|
</form>
|
||||||
|
</ResponsiveSheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useMemo, useEffect } from "react";
|
||||||
import { MoreHorizontal, Plus, Pencil, Trash2 } from "lucide-react";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { DotsThree, Plus, PencilSimple, Trash, List, Columns } from '@/lib/icons';
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
@@ -14,33 +16,58 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { deleteCustomerAction } from "@/lib/appwrite/customer-actions";
|
import { deleteCustomerAction } from "@/lib/appwrite/customer-actions";
|
||||||
import { CustomerFormSheet } from "./customer-form-sheet";
|
import { CustomerFormSheet } from "./customer-form-sheet";
|
||||||
import type { Customer } from "@/lib/appwrite/schema";
|
import { CustomersPipeline } from "./customers-pipeline";
|
||||||
import { CUSTOMER_TYPE_LABELS } from "@/lib/appwrite/schema";
|
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||||
|
import type { Customer, CustomerType, CustomerStage } from "@/lib/appwrite/schema";
|
||||||
|
import {
|
||||||
|
CUSTOMER_TYPE_LABELS,
|
||||||
|
CUSTOMER_STAGE_LABELS,
|
||||||
|
CUSTOMER_SOURCE_LABELS,
|
||||||
|
} from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
|
type ViewMode = "list" | "pipeline";
|
||||||
|
|
||||||
interface CustomersClientProps {
|
interface CustomersClientProps {
|
||||||
initialCustomers: Customer[];
|
initialCustomers: Customer[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CustomersClient({ initialCustomers }: CustomersClientProps) {
|
export function CustomersClient({ initialCustomers }: CustomersClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
const [customers, setCustomers] = useState(initialCustomers);
|
const [customers, setCustomers] = useState(initialCustomers);
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Customer | null>(null);
|
const [editing, setEditing] = useState<Customer | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Customer | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
||||||
|
const [typeFilter, setTypeFilter] = useState<CustomerType | "all">("all");
|
||||||
|
const [stageFilter, setStageFilter] = useState<CustomerStage | "all">("all");
|
||||||
|
|
||||||
function openCreate() {
|
const filteredCustomers = useMemo(() => {
|
||||||
setEditing(null);
|
let list = customers;
|
||||||
setSheetOpen(true);
|
if (typeFilter !== "all") list = list.filter((c) => c.type === typeFilter);
|
||||||
}
|
if (stageFilter !== "all") list = list.filter((c) => (c.stage ?? "ilk_temas") === stageFilter);
|
||||||
|
return list;
|
||||||
|
}, [customers, typeFilter, stageFilter]);
|
||||||
|
|
||||||
function openEdit(c: Customer) {
|
useEffect(() => {
|
||||||
setEditing(c);
|
const open = () => { setEditing(null); setSheetOpen(true); };
|
||||||
setSheetOpen(true);
|
const close = () => setSheetOpen(false);
|
||||||
}
|
window.addEventListener("kovak:open-form-customers", open);
|
||||||
|
window.addEventListener("kovak:close-form-customers", close);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("kovak:open-form-customers", open);
|
||||||
|
window.removeEventListener("kovak:close-form-customers", close);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function handleDelete(c: Customer) {
|
function openCreate() { setEditing(null); setSheetOpen(true); }
|
||||||
if (!confirm(`"${c.name}" silinsin mi?`)) return;
|
function openEdit(c: Customer) { setEditing(c); setSheetOpen(true); }
|
||||||
const result = await deleteCustomerAction(c.$id);
|
|
||||||
|
async function doDelete() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
const result = await deleteCustomerAction(deleteTarget.$id);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
setCustomers((prev) => prev.filter((x) => x.$id !== c.$id));
|
setCustomers((prev) => prev.filter((x) => x.$id !== deleteTarget.$id));
|
||||||
|
setDeleteTarget(null);
|
||||||
toast.success("Müşteri silindi.");
|
toast.success("Müşteri silindi.");
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error ?? "Silinemedi.");
|
toast.error(result.error ?? "Silinemedi.");
|
||||||
@@ -48,77 +75,193 @@ export function CustomersClient({ initialCustomers }: CustomersClientProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4 flex-1">
|
||||||
<div className="flex items-center justify-between">
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
<h1 className="text-2xl font-bold">Müşteriler</h1>
|
<h1 className="text-2xl font-bold">Müşteriler</h1>
|
||||||
<Button onClick={openCreate} size="sm">
|
<div className="flex items-center gap-2">
|
||||||
<Plus className="mr-1.5 size-4" />
|
{/* View toggle */}
|
||||||
Yeni Müşteri
|
<div className="flex rounded-md border overflow-hidden">
|
||||||
</Button>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setViewMode("list")}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm transition-colors ${
|
||||||
|
viewMode === "list"
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-background text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<List className="size-3.5" />
|
||||||
|
Liste
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setViewMode("pipeline")}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm transition-colors border-l ${
|
||||||
|
viewMode === "pipeline"
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-background text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Columns className="size-3.5" />
|
||||||
|
Pipeline
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreate} size="sm" data-tour="customers-add">
|
||||||
|
<Plus className="mr-1.5 size-4" />
|
||||||
|
Yeni Müşteri
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border">
|
{/* Filter bar */}
|
||||||
<Table>
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
<TableHeader>
|
<div className="flex gap-1">
|
||||||
<TableRow>
|
{([["all", "Tümü"], ["alici", "Alıcı"], ["kiraci", "Kiracı"], ["yatirimci", "Yatırımcı"]] as const).map(([key, label]) => {
|
||||||
<TableHead>Ad Soyad</TableHead>
|
const count = key === "all" ? customers.length : customers.filter((c) => c.type === key).length;
|
||||||
<TableHead>Tip</TableHead>
|
if (key !== "all" && count === 0) return null;
|
||||||
<TableHead>Telefon</TableHead>
|
return (
|
||||||
<TableHead>Email</TableHead>
|
<button key={key} type="button"
|
||||||
<TableHead />
|
onClick={() => setTypeFilter(key)}
|
||||||
</TableRow>
|
className={`px-3 py-1 text-xs rounded-full font-medium transition-colors ${
|
||||||
</TableHeader>
|
typeFilter === key ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:text-foreground"
|
||||||
<TableBody>
|
}`}
|
||||||
{customers.length === 0 && (
|
>
|
||||||
<TableRow>
|
{label} <span className="opacity-70">{count}</span>
|
||||||
<TableCell colSpan={5} className="text-muted-foreground text-center py-10">
|
</button>
|
||||||
Henüz müşteri yok.
|
);
|
||||||
</TableCell>
|
})}
|
||||||
</TableRow>
|
</div>
|
||||||
)}
|
<div className="flex gap-1 ml-2 pl-2 border-l">
|
||||||
{customers.map((c) => (
|
{(["all", "ilk_temas", "aktif_arama", "teklif", "sozlesme", "kapandi"] as const).map((key) => {
|
||||||
<TableRow key={c.$id}>
|
const label = key === "all" ? "Tüm aşamalar" : CUSTOMER_STAGE_LABELS[key];
|
||||||
<TableCell className="font-medium">{c.name}</TableCell>
|
const count = key === "all" ? customers.length : customers.filter((c) => (c.stage ?? "ilk_temas") === key).length;
|
||||||
<TableCell>
|
if (key !== "all" && count === 0) return null;
|
||||||
<Badge variant="outline">
|
return (
|
||||||
{CUSTOMER_TYPE_LABELS[c.type] ?? c.type}
|
<button key={key} type="button"
|
||||||
</Badge>
|
onClick={() => setStageFilter(key)}
|
||||||
</TableCell>
|
className={`px-3 py-1 text-xs rounded-full font-medium transition-colors ${
|
||||||
<TableCell className="text-muted-foreground">{c.phone ?? "—"}</TableCell>
|
stageFilter === key ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:text-foreground"
|
||||||
<TableCell className="text-muted-foreground">{c.email ?? "—"}</TableCell>
|
}`}
|
||||||
<TableCell>
|
>
|
||||||
<DropdownMenu>
|
{label} <span className="opacity-70">{count}</span>
|
||||||
<DropdownMenuTrigger asChild>
|
</button>
|
||||||
<Button variant="ghost" size="icon" className="size-8">
|
);
|
||||||
<MoreHorizontal className="size-4" />
|
})}
|
||||||
</Button>
|
</div>
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => openEdit(c)}>
|
|
||||||
<Pencil className="mr-2 size-4" />
|
|
||||||
Düzenle
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleDelete(c)}
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 size-4" />
|
|
||||||
Sil
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pipeline view */}
|
||||||
|
{viewMode === "pipeline" && (
|
||||||
|
<CustomersPipeline customers={filteredCustomers} onEdit={openEdit} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* List view */}
|
||||||
|
{viewMode === "list" && (
|
||||||
|
<div data-tour="customers-table" className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Ad Soyad</TableHead>
|
||||||
|
<TableHead>Tip</TableHead>
|
||||||
|
<TableHead>Aşama</TableHead>
|
||||||
|
<TableHead>Telefon</TableHead>
|
||||||
|
<TableHead>Kaynak</TableHead>
|
||||||
|
<TableHead>Takip</TableHead>
|
||||||
|
<TableHead />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredCustomers.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-muted-foreground text-center py-10">
|
||||||
|
{customers.length === 0 ? "Henüz müşteri yok." : "Filtreye uyan müşteri yok."}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{filteredCustomers.map((c) => (
|
||||||
|
<TableRow key={c.$id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<Link href={`/customers/${c.$id}`} className="hover:underline">
|
||||||
|
{c.name}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{CUSTOMER_TYPE_LABELS[c.type] ?? c.type}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StageBadge stage={c.stage} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{c.phone ?? "—"}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{c.source ? (CUSTOMER_SOURCE_LABELS[c.source] ?? c.source) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-sm">
|
||||||
|
{c.nextFollowUpDate
|
||||||
|
? new Date(c.nextFollowUpDate).toLocaleDateString("tr-TR")
|
||||||
|
: "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="size-8">
|
||||||
|
<DotsThree className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => openEdit(c)}>
|
||||||
|
<PencilSimple className="mr-2 size-4" />
|
||||||
|
Düzenle
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setDeleteTarget(c)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash className="mr-2 size-4" />
|
||||||
|
Sil
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<CustomerFormSheet
|
<CustomerFormSheet
|
||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
onOpenChange={setSheetOpen}
|
onOpenChange={setSheetOpen}
|
||||||
customer={editing}
|
customer={editing}
|
||||||
|
onSuccess={() => router.refresh()}
|
||||||
|
/>
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(v) => { if (!v) setDeleteTarget(null); }}
|
||||||
|
title={`"${deleteTarget?.name}" silinsin mi?`}
|
||||||
|
description="Bu müşteri kalıcı olarak silinecek ve geri alınamaz."
|
||||||
|
onConfirm={doDelete}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StageBadge({ stage }: { stage?: string | null }) {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
ilk_temas: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300",
|
||||||
|
aktif_arama: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300",
|
||||||
|
teklif: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
|
||||||
|
sozlesme: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300",
|
||||||
|
kapandi: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
|
||||||
|
};
|
||||||
|
const key = stage ?? "ilk_temas";
|
||||||
|
const label = CUSTOMER_STAGE_LABELS[key as keyof typeof CUSTOMER_STAGE_LABELS] ?? key;
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex text-xs font-medium px-2 py-0.5 rounded-full ${colors[key] ?? colors.ilk_temas}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Phone, Envelope, CaretRight } from '@/lib/icons';
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { updateCustomerStageAction } from "@/lib/appwrite/customer-actions";
|
||||||
|
import {
|
||||||
|
CUSTOMER_STAGE_LABELS,
|
||||||
|
CUSTOMER_TYPE_LABELS,
|
||||||
|
type Customer,
|
||||||
|
type CustomerStage,
|
||||||
|
} from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
|
const STAGES: CustomerStage[] = [
|
||||||
|
"ilk_temas",
|
||||||
|
"aktif_arama",
|
||||||
|
"teklif",
|
||||||
|
"sozlesme",
|
||||||
|
"kapandi",
|
||||||
|
];
|
||||||
|
|
||||||
|
const STAGE_COLORS: Record<CustomerStage, string> = {
|
||||||
|
ilk_temas: "bg-slate-100 dark:bg-slate-800/60",
|
||||||
|
aktif_arama: "bg-blue-50 dark:bg-blue-950/40",
|
||||||
|
teklif: "bg-amber-50 dark:bg-amber-950/40",
|
||||||
|
sozlesme: "bg-purple-50 dark:bg-purple-950/40",
|
||||||
|
kapandi: "bg-emerald-50 dark:bg-emerald-950/40",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STAGE_HEADER_COLORS: Record<CustomerStage, string> = {
|
||||||
|
ilk_temas: "border-slate-300 text-slate-600 dark:border-slate-600 dark:text-slate-400",
|
||||||
|
aktif_arama: "border-blue-300 text-blue-700 dark:border-blue-600 dark:text-blue-400",
|
||||||
|
teklif: "border-amber-300 text-amber-700 dark:border-amber-600 dark:text-amber-400",
|
||||||
|
sozlesme: "border-purple-300 text-purple-700 dark:border-purple-600 dark:text-purple-400",
|
||||||
|
kapandi: "border-emerald-300 text-emerald-700 dark:border-emerald-600 dark:text-emerald-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CustomersPipelineProps {
|
||||||
|
customers: Customer[];
|
||||||
|
onEdit: (c: Customer) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomersPipeline({ customers, onEdit }: CustomersPipelineProps) {
|
||||||
|
const [items, setItems] = useState(customers);
|
||||||
|
|
||||||
|
async function moveStage(customer: Customer, stage: CustomerStage) {
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((c) => (c.$id === customer.$id ? { ...c, stage } : c)),
|
||||||
|
);
|
||||||
|
const result = await updateCustomerStageAction(customer.$id, stage);
|
||||||
|
if (!result.ok) {
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((c) => (c.$id === customer.$id ? customer : c)),
|
||||||
|
);
|
||||||
|
toast.error(result.error ?? "Aşama güncellenemedi.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = STAGES.reduce<Record<CustomerStage, Customer[]>>(
|
||||||
|
(acc, s) => {
|
||||||
|
acc[s] = items.filter((c) => (c.stage ?? "ilk_temas") === s);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<CustomerStage, Customer[]>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3 overflow-x-auto pb-2 min-h-0 flex-1">
|
||||||
|
{STAGES.map((stage) => (
|
||||||
|
<div key={stage} className="flex flex-col gap-2 w-60 shrink-0">
|
||||||
|
{/* Column header */}
|
||||||
|
<div className={`flex items-center justify-between rounded-lg border px-3 py-2 ${STAGE_HEADER_COLORS[stage]}`}>
|
||||||
|
<span className="text-sm font-semibold">{CUSTOMER_STAGE_LABELS[stage]}</span>
|
||||||
|
<span className="text-xs font-mono bg-white/60 dark:bg-black/20 rounded px-1.5 py-0.5">
|
||||||
|
{grouped[stage].length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cards */}
|
||||||
|
<div className={`flex flex-col gap-2 rounded-xl p-2 min-h-[6rem] flex-1 ${STAGE_COLORS[stage]}`}>
|
||||||
|
{grouped[stage].length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center py-4">Müşteri yok</p>
|
||||||
|
)}
|
||||||
|
{grouped[stage].map((c) => (
|
||||||
|
<CustomerCard
|
||||||
|
key={c.$id}
|
||||||
|
customer={c}
|
||||||
|
currentStage={stage}
|
||||||
|
onMove={moveStage}
|
||||||
|
onEdit={onEdit}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomerCard({
|
||||||
|
customer,
|
||||||
|
currentStage,
|
||||||
|
onMove,
|
||||||
|
onEdit,
|
||||||
|
}: {
|
||||||
|
customer: Customer;
|
||||||
|
currentStage: CustomerStage;
|
||||||
|
onMove: (c: Customer, s: CustomerStage) => void;
|
||||||
|
onEdit: (c: Customer) => void;
|
||||||
|
}) {
|
||||||
|
const currentIdx = STAGES.indexOf(currentStage);
|
||||||
|
const nextStage = STAGES[currentIdx + 1] as CustomerStage | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-card rounded-lg border p-3 space-y-2 shadow-xs hover:shadow-sm transition-shadow cursor-pointer group"
|
||||||
|
onClick={() => onEdit(customer)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-1">
|
||||||
|
<p className="text-sm font-medium leading-snug line-clamp-2 flex-1">{customer.name}</p>
|
||||||
|
<Badge variant="outline" className="text-[10px] px-1 py-0 shrink-0">
|
||||||
|
{CUSTOMER_TYPE_LABELS[customer.type] ?? customer.type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(customer.phone || customer.email) && (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{customer.phone && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Phone className="size-3" />
|
||||||
|
<span className="truncate">{customer.phone}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{customer.email && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Envelope className="size-3" />
|
||||||
|
<span className="truncate">{customer.email}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{customer.nextFollowUpDate && (
|
||||||
|
<p className="text-[11px] text-amber-600 dark:text-amber-400 font-medium">
|
||||||
|
📅 {new Date(customer.nextFollowUpDate).toLocaleDateString("tr-TR")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nextStage && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onMove(customer, nextStage); }}
|
||||||
|
className="w-full flex items-center justify-center gap-1 text-[11px] text-muted-foreground hover:text-foreground border border-dashed rounded py-0.5 hover:border-foreground/30 transition-colors opacity-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<CaretRight className="size-3" />
|
||||||
|
{CUSTOMER_STAGE_LABELS[nextStage]}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState, useEffect } from "react";
|
import { useActionState, useEffect } from "react";
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import { ResponsiveSheet, FormWizard } from "@/components/ui/responsive-sheet";
|
||||||
Sheet,
|
import { createCustomerSearchAction, updateCustomerSearchAction } from "@/lib/appwrite/customer-search-actions";
|
||||||
SheetContent,
|
|
||||||
SheetFooter,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from "@/components/ui/sheet";
|
|
||||||
import {
|
|
||||||
createCustomerSearchAction,
|
|
||||||
updateCustomerSearchAction,
|
|
||||||
} from "@/lib/appwrite/customer-search-actions";
|
|
||||||
import type { Customer, CustomerSearch } from "@/lib/appwrite/schema";
|
import type { Customer, CustomerSearch } from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
type ActionState = { ok: boolean; error?: string; fieldErrors?: Record<string, string[]> };
|
type ActionState = { ok: boolean; error?: string; fieldErrors?: Record<string, string[]> };
|
||||||
@@ -34,20 +23,23 @@ const WEIGHT_OPTIONS = [
|
|||||||
|
|
||||||
function WeightSelect({ name, defaultValue }: { name: string; defaultValue?: number | null }) {
|
function WeightSelect({ name, defaultValue }: { name: string; defaultValue?: number | null }) {
|
||||||
return (
|
return (
|
||||||
<select
|
<div className="flex items-center gap-2">
|
||||||
name={name}
|
<span className="text-muted-foreground text-xs shrink-0">Önem:</span>
|
||||||
defaultValue={String(defaultValue ?? 3)}
|
<select name={name} defaultValue={String(defaultValue ?? 3)}
|
||||||
className="border-input bg-background h-8 rounded-md border px-2 text-xs w-40"
|
className="border-input bg-background h-8 rounded-md border px-2 text-xs">
|
||||||
>
|
{WEIGHT_OPTIONS.map((o) => (
|
||||||
{WEIGHT_OPTIONS.map((o) => (
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
<option key={o.value} value={o.value}>
|
))}
|
||||||
{o.label}
|
</select>
|
||||||
</option>
|
</div>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseJsonToInput(json?: string | null): string {
|
||||||
|
if (!json) return "";
|
||||||
|
try { return (JSON.parse(json) as string[]).join(", "); } catch { return json; }
|
||||||
|
}
|
||||||
|
|
||||||
interface SearchFormSheetProps {
|
interface SearchFormSheetProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (v: boolean) => void;
|
onOpenChange: (v: boolean) => void;
|
||||||
@@ -57,18 +49,8 @@ interface SearchFormSheetProps {
|
|||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchFormSheet({
|
export function SearchFormSheet({ open, onOpenChange, search, customers, defaultCustomerId, onSuccess }: SearchFormSheetProps) {
|
||||||
open,
|
const action = search ? updateCustomerSearchAction.bind(null, search.$id) : createCustomerSearchAction;
|
||||||
onOpenChange,
|
|
||||||
search,
|
|
||||||
customers,
|
|
||||||
defaultCustomerId,
|
|
||||||
onSuccess,
|
|
||||||
}: SearchFormSheetProps) {
|
|
||||||
const action = search
|
|
||||||
? updateCustomerSearchAction.bind(null, search.$id)
|
|
||||||
: createCustomerSearchAction;
|
|
||||||
|
|
||||||
const [state, formAction, isPending] = useActionState(action, INITIAL);
|
const [state, formAction, isPending] = useActionState(action, INITIAL);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -83,165 +65,108 @@ export function SearchFormSheet({
|
|||||||
|
|
||||||
const fe = state.fieldErrors ?? {};
|
const fe = state.fieldErrors ?? {};
|
||||||
|
|
||||||
function parseJsonToInput(json?: string | null): string {
|
const steps = [
|
||||||
if (!json) return "";
|
{
|
||||||
try {
|
label: "Müşteri",
|
||||||
const arr = JSON.parse(json) as string[];
|
content: (
|
||||||
return arr.join(", ");
|
<>
|
||||||
} catch {
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
||||||
<SheetContent className="sm:max-w-md overflow-y-auto">
|
|
||||||
<SheetHeader>
|
|
||||||
<SheetTitle>{search ? "Aramayı Düzenle" : "Yeni Arama Kriteri"}</SheetTitle>
|
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
<form action={formAction} className="mt-4 space-y-5 pb-6">
|
|
||||||
{/* Müşteri */}
|
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label>Müşteri *</Label>
|
<Label>Müşteri *</Label>
|
||||||
<select
|
<select name="customerId" defaultValue={search?.customerId ?? defaultCustomerId ?? ""}
|
||||||
name="customerId"
|
className="border-input bg-background h-9 rounded-md border px-3 text-sm">
|
||||||
defaultValue={search?.customerId ?? defaultCustomerId ?? ""}
|
|
||||||
className="border-input bg-background h-9 rounded-md border px-3 text-sm"
|
|
||||||
>
|
|
||||||
<option value="">Müşteri seçin</option>
|
<option value="">Müşteri seçin</option>
|
||||||
{customers.map((c) => (
|
{customers.map((c) => (
|
||||||
<option key={c.$id} value={c.$id}>
|
<option key={c.$id} value={c.$id}>{c.name}</option>
|
||||||
{c.name}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{fe.customerId && <p className="text-destructive text-xs">{fe.customerId[0]}</p>}
|
{fe.customerId && <p className="text-destructive text-xs">{fe.customerId[0]}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* İlan türü */}
|
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label>İlan türü</Label>
|
<Label>İlan türü</Label>
|
||||||
<select
|
<select name="listingType" defaultValue={search?.listingType ?? ""}
|
||||||
name="listingType"
|
className="border-input bg-background h-9 rounded-md border px-3 text-sm">
|
||||||
defaultValue={search?.listingType ?? ""}
|
|
||||||
className="border-input bg-background h-9 rounded-md border px-3 text-sm"
|
|
||||||
>
|
|
||||||
<option value="">Tümü</option>
|
<option value="">Tümü</option>
|
||||||
<option value="satilik">Satılık</option>
|
<option value="satilik">Satılık</option>
|
||||||
<option value="kiralik">Kiralık</option>
|
<option value="kiralik">Kiralık</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
{/* Emlak tipi + ağırlık */}
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Emlak & Boyut",
|
||||||
|
content: (
|
||||||
|
<div className="grid grid-cols-2 gap-x-5 gap-y-4">
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="propertyTypes">Emlak tipleri</Label>
|
<Label>Emlak tipleri</Label>
|
||||||
<p className="text-muted-foreground text-xs">Virgülle ayırın. Örn: daire, villa</p>
|
<Input name="propertyTypes" defaultValue={parseJsonToInput(search?.propertyTypes)} placeholder="daire, villa" />
|
||||||
<Input
|
<WeightSelect name="propertyTypeWeight" defaultValue={search?.propertyTypeWeight} />
|
||||||
id="propertyTypes"
|
|
||||||
name="propertyTypes"
|
|
||||||
defaultValue={parseJsonToInput(search?.propertyTypes)}
|
|
||||||
placeholder="daire, villa"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
|
||||||
<span className="text-muted-foreground text-xs">Önem:</span>
|
|
||||||
<WeightSelect name="propertyTypeWeight" defaultValue={search?.propertyTypeWeight} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Oda sayısı + ağırlık */}
|
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="roomCounts">Oda sayıları</Label>
|
<Label>Oda sayıları</Label>
|
||||||
<p className="text-muted-foreground text-xs">Virgülle ayırın. Örn: 2+1, 3+1</p>
|
<Input name="roomCounts" defaultValue={parseJsonToInput(search?.roomCounts)} placeholder="2+1, 3+1" />
|
||||||
<Input
|
<WeightSelect name="roomCountWeight" defaultValue={search?.roomCountWeight} />
|
||||||
id="roomCounts"
|
|
||||||
name="roomCounts"
|
|
||||||
defaultValue={parseJsonToInput(search?.roomCounts)}
|
|
||||||
placeholder="2+1, 3+1"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
|
||||||
<span className="text-muted-foreground text-xs">Önem:</span>
|
|
||||||
<WeightSelect name="roomCountWeight" defaultValue={search?.roomCountWeight} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fiyat aralığı + ağırlık */}
|
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label>Fiyat aralığı</Label>
|
<Label>Fiyat aralığı</Label>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div className="grid gap-1">
|
<div>
|
||||||
<span className="text-muted-foreground text-xs">Min</span>
|
<span className="text-muted-foreground text-xs block mb-1">Min</span>
|
||||||
<Input name="minPrice" type="number" min="0" defaultValue={search?.minPrice ?? ""} />
|
<Input name="minPrice" type="number" min="0" defaultValue={search?.minPrice ?? ""} placeholder="0" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-1">
|
<div>
|
||||||
<span className="text-muted-foreground text-xs">Max</span>
|
<span className="text-muted-foreground text-xs block mb-1">Max</span>
|
||||||
<Input name="maxPrice" type="number" min="0" defaultValue={search?.maxPrice ?? ""} />
|
<Input name="maxPrice" type="number" min="0" defaultValue={search?.maxPrice ?? ""} placeholder="—" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
<WeightSelect name="priceWeight" defaultValue={search?.priceWeight} />
|
||||||
<span className="text-muted-foreground text-xs">Önem:</span>
|
|
||||||
<WeightSelect name="priceWeight" defaultValue={search?.priceWeight} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* M2 aralığı + ağırlık */}
|
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label>m² aralığı</Label>
|
<Label>m² aralığı</Label>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div className="grid gap-1">
|
<div>
|
||||||
<span className="text-muted-foreground text-xs">Min</span>
|
<span className="text-muted-foreground text-xs block mb-1">Min</span>
|
||||||
<Input name="minM2" type="number" min="0" defaultValue={search?.minM2 ?? ""} />
|
<Input name="minM2" type="number" min="0" defaultValue={search?.minM2 ?? ""} placeholder="0" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-1">
|
<div>
|
||||||
<span className="text-muted-foreground text-xs">Max</span>
|
<span className="text-muted-foreground text-xs block mb-1">Max</span>
|
||||||
<Input name="maxM2" type="number" min="0" defaultValue={search?.maxM2 ?? ""} />
|
<Input name="maxM2" type="number" min="0" defaultValue={search?.maxM2 ?? ""} placeholder="—" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
<WeightSelect name="m2Weight" defaultValue={search?.m2Weight} />
|
||||||
<span className="text-muted-foreground text-xs">Önem:</span>
|
|
||||||
<WeightSelect name="m2Weight" defaultValue={search?.m2Weight} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* Konum + ağırlık */}
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Konum",
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label>Konum</Label>
|
<Label>Şehirler</Label>
|
||||||
<div className="grid gap-1">
|
<Input name="cities" defaultValue={parseJsonToInput(search?.cities)} placeholder="İstanbul, Ankara" />
|
||||||
<span className="text-muted-foreground text-xs">Şehirler (virgülle ayırın)</span>
|
<WeightSelect name="locationWeight" defaultValue={search?.locationWeight} />
|
||||||
<Input
|
|
||||||
name="cities"
|
|
||||||
defaultValue={parseJsonToInput(search?.cities)}
|
|
||||||
placeholder="İstanbul, Ankara"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-1 mt-1">
|
|
||||||
<span className="text-muted-foreground text-xs">İlçeler (virgülle ayırın)</span>
|
|
||||||
<Input
|
|
||||||
name="districts"
|
|
||||||
defaultValue={parseJsonToInput(search?.districts)}
|
|
||||||
placeholder="Kadıköy, Beşiktaş"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
|
||||||
<span className="text-muted-foreground text-xs">Önem:</span>
|
|
||||||
<WeightSelect name="locationWeight" defaultValue={search?.locationWeight} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notlar */}
|
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="notes">Notlar</Label>
|
<Label>İlçeler</Label>
|
||||||
<Textarea id="notes" name="notes" rows={2} defaultValue={search?.notes ?? ""} />
|
<Input name="districts" defaultValue={parseJsonToInput(search?.districts)} placeholder="Kadıköy, Beşiktaş" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label>Notlar</Label>
|
||||||
|
<Textarea name="notes" rows={3} defaultValue={search?.notes ?? ""} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
<SheetFooter>
|
return (
|
||||||
<Button type="submit" disabled={isPending} className="w-full">
|
<ResponsiveSheet open={open} onOpenChange={onOpenChange}
|
||||||
{isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
|
title={search ? "Aramayı Düzenle" : "Yeni Arama Kriteri"}
|
||||||
{search ? "Güncelle" : "Oluştur"}
|
maxWidth="sm:max-w-xl">
|
||||||
</Button>
|
<form action={formAction}>
|
||||||
</SheetFooter>
|
<FormWizard steps={steps} isPending={isPending} submitLabel={search ? "Güncelle" : "Oluştur"} />
|
||||||
</form>
|
</form>
|
||||||
</SheetContent>
|
</ResponsiveSheet>
|
||||||
</Sheet>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { MoreHorizontal, Plus, Pencil, Trash2, ToggleLeft } from "lucide-react";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { DotsThree, Plus, PencilSimple, Trash, ToggleLeft } from '@/lib/icons';
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
toggleCustomerSearchActiveAction,
|
toggleCustomerSearchActiveAction,
|
||||||
} from "@/lib/appwrite/customer-search-actions";
|
} from "@/lib/appwrite/customer-search-actions";
|
||||||
import { SearchFormSheet } from "./search-form-sheet";
|
import { SearchFormSheet } from "./search-form-sheet";
|
||||||
|
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||||
import type { Customer, CustomerSearch } from "@/lib/appwrite/schema";
|
import type { Customer, CustomerSearch } from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
interface SearchesClientProps {
|
interface SearchesClientProps {
|
||||||
@@ -25,23 +27,18 @@ interface SearchesClientProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SearchesClient({ initialSearches, customers }: SearchesClientProps) {
|
export function SearchesClient({ initialSearches, customers }: SearchesClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
const [searches, setSearches] = useState(initialSearches);
|
const [searches, setSearches] = useState(initialSearches);
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<CustomerSearch | null>(null);
|
const [editing, setEditing] = useState<CustomerSearch | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<CustomerSearch | null>(null);
|
||||||
|
|
||||||
function customerName(id: string) {
|
function customerName(id: string) {
|
||||||
return customers.find((c) => c.$id === id)?.name ?? id;
|
return customers.find((c) => c.$id === id)?.name ?? id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() { setEditing(null); setSheetOpen(true); }
|
||||||
setEditing(null);
|
function openEdit(s: CustomerSearch) { setEditing(s); setSheetOpen(true); }
|
||||||
setSheetOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(s: CustomerSearch) {
|
|
||||||
setEditing(s);
|
|
||||||
setSheetOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleToggle(s: CustomerSearch) {
|
async function handleToggle(s: CustomerSearch) {
|
||||||
const next = !s.isActive;
|
const next = !s.isActive;
|
||||||
@@ -53,11 +50,12 @@ export function SearchesClient({ initialSearches, customers }: SearchesClientPro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(s: CustomerSearch) {
|
async function doDelete() {
|
||||||
if (!confirm("Bu arama kriteri silinsin mi?")) return;
|
if (!deleteTarget) return;
|
||||||
const result = await deleteCustomerSearchAction(s.$id);
|
const result = await deleteCustomerSearchAction(deleteTarget.$id);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
setSearches((prev) => prev.filter((x) => x.$id !== s.$id));
|
setSearches((prev) => prev.filter((x) => x.$id !== deleteTarget.$id));
|
||||||
|
setDeleteTarget(null);
|
||||||
toast.success("Arama kriteri silindi.");
|
toast.success("Arama kriteri silindi.");
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error ?? "Silinemedi.");
|
toast.error(result.error ?? "Silinemedi.");
|
||||||
@@ -132,12 +130,12 @@ export function SearchesClient({ initialSearches, customers }: SearchesClientPro
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="size-8">
|
<Button variant="ghost" size="icon" className="size-8">
|
||||||
<MoreHorizontal className="size-4" />
|
<DotsThree className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => openEdit(s)}>
|
<DropdownMenuItem onClick={() => openEdit(s)}>
|
||||||
<Pencil className="mr-2 size-4" />
|
<PencilSimple className="mr-2 size-4" />
|
||||||
Düzenle
|
Düzenle
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => handleToggle(s)}>
|
<DropdownMenuItem onClick={() => handleToggle(s)}>
|
||||||
@@ -145,10 +143,10 @@ export function SearchesClient({ initialSearches, customers }: SearchesClientPro
|
|||||||
{s.isActive ? "Pasif yap" : "Aktif yap"}
|
{s.isActive ? "Pasif yap" : "Aktif yap"}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleDelete(s)}
|
onClick={() => setDeleteTarget(s)}
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 size-4" />
|
<Trash className="mr-2 size-4" />
|
||||||
Sil
|
Sil
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -165,6 +163,14 @@ export function SearchesClient({ initialSearches, customers }: SearchesClientPro
|
|||||||
onOpenChange={setSheetOpen}
|
onOpenChange={setSheetOpen}
|
||||||
search={editing}
|
search={editing}
|
||||||
customers={customers}
|
customers={customers}
|
||||||
|
onSuccess={() => router.refresh()}
|
||||||
|
/>
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(v) => { if (!v) setDeleteTarget(null); }}
|
||||||
|
title="Bu arama kriteri silinsin mi?"
|
||||||
|
description="Arama kriteri ve eşleşme bildirimleri kalıcı olarak silinecek."
|
||||||
|
onConfirm={doDelete}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useActionState, startTransition } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { ResponsiveSheet, FormWizard } from "@/components/ui/responsive-sheet";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { createDealAction, updateDealAction } from "@/lib/appwrite/deal-actions";
|
||||||
|
import type { Customer, Deal } from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
|
interface DealFormSheetProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
deal?: Deal | null;
|
||||||
|
customers: Customer[];
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_STATE: { ok: boolean; error?: string; fieldErrors?: Record<string, string[]> } = { ok: false };
|
||||||
|
|
||||||
|
export function DealFormSheet({ open, onOpenChange, deal, customers, onSuccess }: DealFormSheetProps) {
|
||||||
|
const action = deal ? updateDealAction.bind(null, deal.$id) : createDealAction;
|
||||||
|
const [state, dispatch, isPending] = useActionState(action, EMPTY_STATE);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.ok) {
|
||||||
|
toast.success(deal ? "İşlem güncellendi." : "İşlem kaydedildi.");
|
||||||
|
onSuccess();
|
||||||
|
onOpenChange(false);
|
||||||
|
} else if (state.error) {
|
||||||
|
toast.error(state.error);
|
||||||
|
}
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
const fd = new FormData(e.currentTarget);
|
||||||
|
startTransition(() => dispatch(fd));
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
label: "İşlem",
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label>Tip</Label>
|
||||||
|
<Select name="type" defaultValue={deal?.type ?? "satis"} required>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Seç" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="satis">Satış</SelectItem>
|
||||||
|
<SelectItem value="kiralama">Kiralama</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label>İlan Başlığı</Label>
|
||||||
|
<Input name="propertyTitle" placeholder="İsteğe bağlı" defaultValue={deal?.propertyTitle ?? ""} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label>Müşteri</Label>
|
||||||
|
<select
|
||||||
|
name="customerId"
|
||||||
|
defaultValue={deal?.customerId ?? ""}
|
||||||
|
className="border-input bg-background h-9 rounded-md border px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">— Seçiniz (opsiyonel)</option>
|
||||||
|
{customers.map((c) => (
|
||||||
|
<option key={c.$id} value={c.$id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label>Satış / Kira Bedeli (₺)</Label>
|
||||||
|
<Input name="salePrice" type="number" min={0} step="any" required placeholder="0"
|
||||||
|
defaultValue={deal?.salePrice ?? ""} />
|
||||||
|
{state.fieldErrors?.salePrice && (
|
||||||
|
<p className="text-destructive text-xs">{state.fieldErrors.salePrice[0]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Komisyon",
|
||||||
|
content: (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label>Oran (%)</Label>
|
||||||
|
<Input name="commissionRate" type="number" min={0} max={100} step="any" required placeholder="3"
|
||||||
|
defaultValue={deal?.commissionRate ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label>Tutar (₺)</Label>
|
||||||
|
<Input name="commissionAmount" type="number" min={0} step="any" required placeholder="0"
|
||||||
|
defaultValue={deal?.commissionAmount ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label>Ofis payı (%)</Label>
|
||||||
|
<Input name="officeSharePercent" type="number" min={0} max={100} step="any" placeholder="50"
|
||||||
|
defaultValue={deal?.officeSharePercent ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label>Danışman payı (%)</Label>
|
||||||
|
<Input name="agentSharePercent" type="number" min={0} max={100} step="any" placeholder="50"
|
||||||
|
defaultValue={deal?.agentSharePercent ?? ""} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-3">Referans / Ortak Danışman</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="grid gap-1.5 col-span-2">
|
||||||
|
<Label>Ad Soyad</Label>
|
||||||
|
<Input name="referralName" placeholder="Ahmet Yılmaz (opsiyonel)"
|
||||||
|
defaultValue={deal?.referralName ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label>Telefon</Label>
|
||||||
|
<Input name="referralPhone" placeholder="05xx xxx xx xx"
|
||||||
|
defaultValue={deal?.referralPhone ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label>Komisyondan pay (%)</Label>
|
||||||
|
<Input name="referralPercent" type="number" min={0} max={100} step="any"
|
||||||
|
placeholder="örn. 25"
|
||||||
|
defaultValue={deal?.referralPercent ?? ""} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Kapanış",
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label>Kapanış Tarihi</Label>
|
||||||
|
<Input name="closingDate" type="date"
|
||||||
|
defaultValue={deal?.closingDate ? deal.closingDate.slice(0, 10) : ""} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label>Notlar</Label>
|
||||||
|
<Textarea name="notes" rows={4} placeholder="Opsiyonel notlar..." defaultValue={deal?.notes ?? ""} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveSheet open={open} onOpenChange={onOpenChange}
|
||||||
|
title={deal ? "İşlemi Düzenle" : "Yeni İşlem"}
|
||||||
|
maxWidth="sm:max-w-lg">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<FormWizard steps={steps} isPending={isPending} submitLabel={deal ? "Güncelle" : "Kaydet"} />
|
||||||
|
</form>
|
||||||
|
</ResponsiveSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { DotsThree, Plus, PencilSimple, Trash, CheckCircle, XCircle, TrendUp, Wallet, Users, Clock, UserCheck } from '@/lib/icons';
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||||
|
import { DealFormSheet } from "./deal-form-sheet";
|
||||||
|
import {
|
||||||
|
updateDealStatusAction,
|
||||||
|
deleteDealAction,
|
||||||
|
} from "@/lib/appwrite/deal-actions";
|
||||||
|
import type { Deal, DealStatus, Customer } from "@/lib/appwrite/schema";
|
||||||
|
import {
|
||||||
|
DEAL_TYPE_LABELS,
|
||||||
|
DEAL_STATUS_LABELS,
|
||||||
|
} from "@/lib/appwrite/schema";
|
||||||
|
import type { TenantRole } from "@/lib/appwrite/tenant-guard";
|
||||||
|
|
||||||
|
interface FinanceClientProps {
|
||||||
|
initialDeals: Deal[];
|
||||||
|
role: TenantRole;
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
customers: Customer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_FILTER_OPTIONS = [
|
||||||
|
["all", "Tümü"],
|
||||||
|
["bekleyen", "Bekleyen"],
|
||||||
|
["tahsil_edildi", "Tahsil Edildi"],
|
||||||
|
["iptal", "İptal"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function fmt(n: number) {
|
||||||
|
return n.toLocaleString("tr-TR", { maximumFractionDigits: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FinanceClient({ initialDeals, role, userId, customers }: FinanceClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [deals, setDeals] = useState<Deal[]>(initialDeals);
|
||||||
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<Deal | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Deal | null>(null);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<DealStatus | "all">("all");
|
||||||
|
|
||||||
|
const isOwnerOrAdmin = role === "owner" || role === "admin";
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (statusFilter === "all") return deals;
|
||||||
|
return deals.filter((d) => (d.status ?? "bekleyen") === statusFilter);
|
||||||
|
}, [deals, statusFilter]);
|
||||||
|
|
||||||
|
// ---- Stats ----
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const collected = deals.filter((d) => d.status === "tahsil_edildi");
|
||||||
|
const pending = deals.filter((d) => (d.status ?? "bekleyen") === "bekleyen");
|
||||||
|
|
||||||
|
const totalCommission = collected.reduce((s, d) => s + d.commissionAmount, 0);
|
||||||
|
const pendingCommission = pending.reduce((s, d) => s + d.commissionAmount, 0);
|
||||||
|
|
||||||
|
const agentEarnings = collected.reduce(
|
||||||
|
(s, d) =>
|
||||||
|
s + (d.agentSharePercent != null
|
||||||
|
? (d.commissionAmount * d.agentSharePercent) / 100
|
||||||
|
: d.commissionAmount),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const officeEarnings = collected.reduce(
|
||||||
|
(s, d) =>
|
||||||
|
s + (d.officeSharePercent != null
|
||||||
|
? (d.commissionAmount * d.officeSharePercent) / 100
|
||||||
|
: 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const referralPaid = collected.reduce(
|
||||||
|
(s, d) =>
|
||||||
|
s + (d.referralPercent != null
|
||||||
|
? (d.commissionAmount * d.referralPercent) / 100
|
||||||
|
: 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group by agent for leaderboard (owner/admin only)
|
||||||
|
const byAgent: Record<string, { name: string; count: number; earnings: number }> = {};
|
||||||
|
collected.forEach((d) => {
|
||||||
|
if (!byAgent[d.agentId]) byAgent[d.agentId] = { name: d.agentName, count: 0, earnings: 0 };
|
||||||
|
byAgent[d.agentId].count++;
|
||||||
|
byAgent[d.agentId].earnings +=
|
||||||
|
d.agentSharePercent != null
|
||||||
|
? (d.commissionAmount * d.agentSharePercent) / 100
|
||||||
|
: d.commissionAmount;
|
||||||
|
});
|
||||||
|
const leaderboard = Object.entries(byAgent)
|
||||||
|
.map(([id, v]) => ({ id, ...v }))
|
||||||
|
.sort((a, b) => b.earnings - a.earnings);
|
||||||
|
|
||||||
|
return { totalCommission, pendingCommission, agentEarnings, officeEarnings, referralPaid, leaderboard, pendingCount: pending.length };
|
||||||
|
}, [deals]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const open = () => { setEditing(null); setSheetOpen(true); };
|
||||||
|
const close = () => setSheetOpen(false);
|
||||||
|
window.addEventListener("kovak:open-form-finance", open);
|
||||||
|
window.addEventListener("kovak:close-form-finance", close);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("kovak:open-form-finance", open);
|
||||||
|
window.removeEventListener("kovak:close-form-finance", close);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function openCreate() { setEditing(null); setSheetOpen(true); }
|
||||||
|
function openEdit(d: Deal) { setEditing(d); setSheetOpen(true); }
|
||||||
|
|
||||||
|
async function handleStatusChange(deal: Deal, status: DealStatus) {
|
||||||
|
setDeals((prev) =>
|
||||||
|
prev.map((d) => (d.$id === deal.$id ? { ...d, status } : d)),
|
||||||
|
);
|
||||||
|
const result = await updateDealStatusAction(deal.$id, status);
|
||||||
|
if (!result.ok) {
|
||||||
|
setDeals((prev) =>
|
||||||
|
prev.map((d) => (d.$id === deal.$id ? deal : d)),
|
||||||
|
);
|
||||||
|
toast.error(result.error ?? "Durum güncellenemedi.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDelete() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
const result = await deleteDealAction(deleteTarget.$id);
|
||||||
|
if (result.ok) {
|
||||||
|
setDeals((prev) => prev.filter((d) => d.$id !== deleteTarget.$id));
|
||||||
|
setDeleteTarget(null);
|
||||||
|
toast.success("İşlem silindi.");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error ?? "Silinemedi.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 flex-1">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<h1 className="text-2xl font-bold">Finans</h1>
|
||||||
|
<Button onClick={openCreate} size="sm" data-tour="finance-add">
|
||||||
|
<Plus className="mr-1.5 size-4" />
|
||||||
|
Yeni İşlem
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats cards */}
|
||||||
|
{isOwnerOrAdmin ? (
|
||||||
|
<div data-tour="finance-stats" className="grid grid-cols-2 gap-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
|
<StatCard
|
||||||
|
icon={TrendUp}
|
||||||
|
label="Toplam Komisyon"
|
||||||
|
value={`₺${fmt(stats.totalCommission)}`}
|
||||||
|
sub="tahsil edilen"
|
||||||
|
color="text-emerald-600"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Clock}
|
||||||
|
label="Bekleyen Komisyon"
|
||||||
|
value={`₺${fmt(stats.pendingCommission)}`}
|
||||||
|
sub={`${stats.pendingCount} işlem`}
|
||||||
|
color="text-amber-600"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Wallet}
|
||||||
|
label="Ofis Geliri"
|
||||||
|
value={`₺${fmt(stats.officeEarnings)}`}
|
||||||
|
sub="tahsil edilen paydan"
|
||||||
|
color="text-blue-600"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Users}
|
||||||
|
label="Danışman Geliri"
|
||||||
|
value={`₺${fmt(stats.agentEarnings)}`}
|
||||||
|
sub="tahsil edilen paydan"
|
||||||
|
color="text-purple-600"
|
||||||
|
/>
|
||||||
|
{stats.referralPaid > 0 && (
|
||||||
|
<StatCard
|
||||||
|
icon={UserCheck}
|
||||||
|
label="Referans Ödemesi"
|
||||||
|
value={`₺${fmt(stats.referralPaid)}`}
|
||||||
|
sub="tahsil edilenden"
|
||||||
|
color="text-rose-600"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||||
|
<StatCard
|
||||||
|
icon={TrendUp}
|
||||||
|
label="Kazancım"
|
||||||
|
value={`₺${fmt(stats.agentEarnings)}`}
|
||||||
|
sub="tahsil edilen"
|
||||||
|
color="text-emerald-600"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Clock}
|
||||||
|
label="Bekleyen"
|
||||||
|
value={`₺${fmt(stats.pendingCommission)}`}
|
||||||
|
sub={`${stats.pendingCount} işlem`}
|
||||||
|
color="text-amber-600"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Wallet}
|
||||||
|
label="Toplam İşlem"
|
||||||
|
value={String(deals.length)}
|
||||||
|
sub="kayıtlı"
|
||||||
|
color="text-blue-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Leaderboard (owner/admin only) */}
|
||||||
|
{isOwnerOrAdmin && stats.leaderboard.length > 0 && (
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<p className="text-sm font-semibold mb-3">Danışman Performansı</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{stats.leaderboard.map((agent, i) => (
|
||||||
|
<div key={agent.id} className="flex items-center gap-3">
|
||||||
|
<span className="text-muted-foreground text-xs w-4">{i + 1}.</span>
|
||||||
|
<span className="text-sm flex-1 truncate">{agent.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{agent.count} işlem</span>
|
||||||
|
<span className="text-sm font-medium tabular-nums">₺{fmt(agent.earnings)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status filter */}
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{STATUS_FILTER_OPTIONS.map(([key, label]) => {
|
||||||
|
const count = key === "all" ? deals.length : deals.filter((d) => (d.status ?? "bekleyen") === key).length;
|
||||||
|
if (key !== "all" && count === 0) return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatusFilter(key as DealStatus | "all")}
|
||||||
|
className={`px-3 py-1 text-xs rounded-full font-medium transition-colors ${
|
||||||
|
statusFilter === key
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label} <span className="opacity-70">{count}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div data-tour="finance-table" className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>İlan / Müşteri</TableHead>
|
||||||
|
<TableHead>Tip</TableHead>
|
||||||
|
{isOwnerOrAdmin && <TableHead>Danışman</TableHead>}
|
||||||
|
<TableHead>Satış Bedeli</TableHead>
|
||||||
|
<TableHead>Komisyon</TableHead>
|
||||||
|
<TableHead>Danışman Payı</TableHead>
|
||||||
|
<TableHead>Durum</TableHead>
|
||||||
|
<TableHead>Tarih</TableHead>
|
||||||
|
<TableHead />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={isOwnerOrAdmin ? 9 : 8}
|
||||||
|
className="text-muted-foreground text-center py-10"
|
||||||
|
>
|
||||||
|
{deals.length === 0 ? "Henüz işlem yok." : "Filtreye uyan işlem yok."}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{filtered.map((deal) => {
|
||||||
|
const agentEarning =
|
||||||
|
deal.agentSharePercent != null
|
||||||
|
? (deal.commissionAmount * deal.agentSharePercent) / 100
|
||||||
|
: deal.commissionAmount;
|
||||||
|
const canEdit = isOwnerOrAdmin || deal.agentId === userId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={deal.$id} className={deal.status === "iptal" ? "opacity-50" : ""}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium text-sm truncate max-w-[180px]">
|
||||||
|
{deal.propertyTitle ?? "—"}
|
||||||
|
</span>
|
||||||
|
{deal.customerName && (
|
||||||
|
<span className="text-muted-foreground text-xs">{deal.customerName}</span>
|
||||||
|
)}
|
||||||
|
{deal.referralName && (
|
||||||
|
<span className="text-xs text-rose-600 flex items-center gap-0.5 mt-0.5">
|
||||||
|
<UserCheck className="size-3 shrink-0" />
|
||||||
|
{deal.referralName}
|
||||||
|
{deal.referralPercent != null && ` (%${deal.referralPercent})`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{DEAL_TYPE_LABELS[deal.type]}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
{isOwnerOrAdmin && (
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{deal.agentName}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
<TableCell className="tabular-nums text-sm">
|
||||||
|
₺{fmt(deal.salePrice)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="tabular-nums text-sm">
|
||||||
|
₺{fmt(deal.commissionAmount)}
|
||||||
|
<span className="text-muted-foreground text-xs ml-1">
|
||||||
|
(%{deal.commissionRate})
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="tabular-nums text-sm font-medium">
|
||||||
|
₺{fmt(agentEarning)}
|
||||||
|
{deal.agentSharePercent != null && (
|
||||||
|
<span className="text-muted-foreground text-xs ml-1">
|
||||||
|
(%{deal.agentSharePercent})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<DealStatusBadge status={deal.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-sm">
|
||||||
|
{deal.closingDate
|
||||||
|
? new Date(deal.closingDate).toLocaleDateString("tr-TR")
|
||||||
|
: "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="size-8">
|
||||||
|
<DotsThree className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{canEdit && (
|
||||||
|
<DropdownMenuItem onClick={() => openEdit(deal)}>
|
||||||
|
<PencilSimple className="mr-2 size-4" />
|
||||||
|
Düzenle
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{isOwnerOrAdmin && deal.status !== "tahsil_edildi" && (
|
||||||
|
<DropdownMenuItem onClick={() => handleStatusChange(deal, "tahsil_edildi")}>
|
||||||
|
<CheckCircle className="mr-2 size-4 text-emerald-600" />
|
||||||
|
Tahsil Edildi
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{isOwnerOrAdmin && deal.status !== "iptal" && (
|
||||||
|
<DropdownMenuItem onClick={() => handleStatusChange(deal, "iptal")}>
|
||||||
|
<XCircle className="mr-2 size-4 text-destructive" />
|
||||||
|
İptal Et
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{isOwnerOrAdmin && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setDeleteTarget(deal)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash className="mr-2 size-4" />
|
||||||
|
Sil
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DealFormSheet
|
||||||
|
open={sheetOpen}
|
||||||
|
onOpenChange={setSheetOpen}
|
||||||
|
deal={editing}
|
||||||
|
customers={customers}
|
||||||
|
onSuccess={() => router.refresh()}
|
||||||
|
/>
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(v) => { if (!v) setDeleteTarget(null); }}
|
||||||
|
title={`Bu işlem silinsin mi?`}
|
||||||
|
description="İşlem kalıcı olarak silinecek ve geri alınamaz."
|
||||||
|
onConfirm={doDelete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
icon: React.ElementType;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sub: string;
|
||||||
|
color: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-card p-4 flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className={`size-4 ${color}`} />
|
||||||
|
<span className="text-muted-foreground text-xs">{label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-bold tabular-nums">{value}</p>
|
||||||
|
<p className="text-muted-foreground text-xs">{sub}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DealStatusBadge({ status }: { status?: string | null }) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
bekleyen: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
|
||||||
|
tahsil_edildi: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
|
||||||
|
iptal: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300",
|
||||||
|
};
|
||||||
|
const key = status ?? "bekleyen";
|
||||||
|
const label = DEAL_STATUS_LABELS[key as keyof typeof DEAL_STATUS_LABELS] ?? key;
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex text-xs font-medium px-2 py-0.5 rounded-full ${map[key] ?? map.bekleyen}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Building2, User } from "lucide-react";
|
import { Buildings, User } from '@/lib/icons';
|
||||||
|
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -36,7 +36,7 @@ export function ScopeToggle({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 text-sm font-medium">
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
<Building2 className="size-4" />
|
<Buildings className="size-4" />
|
||||||
Şirket
|
Şirket
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
|
|||||||
@@ -1,20 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState, useEffect } from "react";
|
import { useActionState, useEffect } from "react";
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import { ResponsiveSheet, FormWizard } from "@/components/ui/responsive-sheet";
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetFooter,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from "@/components/ui/sheet";
|
|
||||||
import { createInvestorAction, updateInvestorAction } from "@/lib/appwrite/investor-actions";
|
import { createInvestorAction, updateInvestorAction } from "@/lib/appwrite/investor-actions";
|
||||||
import type { Investor } from "@/lib/appwrite/schema";
|
import type { Investor } from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
@@ -29,10 +21,7 @@ interface InvestorFormSheetProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function InvestorFormSheet({ open, onOpenChange, investor, onSuccess }: InvestorFormSheetProps) {
|
export function InvestorFormSheet({ open, onOpenChange, investor, onSuccess }: InvestorFormSheetProps) {
|
||||||
const action = investor
|
const action = investor ? updateInvestorAction.bind(null, investor.$id) : createInvestorAction;
|
||||||
? updateInvestorAction.bind(null, investor.$id)
|
|
||||||
: createInvestorAction;
|
|
||||||
|
|
||||||
const [state, formAction, isPending] = useActionState(action, INITIAL);
|
const [state, formAction, isPending] = useActionState(action, INITIAL);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -47,63 +36,76 @@ export function InvestorFormSheet({ open, onOpenChange, investor, onSuccess }: I
|
|||||||
|
|
||||||
const fe = state.fieldErrors ?? {};
|
const fe = state.fieldErrors ?? {};
|
||||||
|
|
||||||
return (
|
const steps = [
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
{
|
||||||
<SheetContent className="sm:max-w-md">
|
label: "Kişi",
|
||||||
<SheetHeader>
|
content: (
|
||||||
<SheetTitle>{investor ? "Yatırımcıyı Düzenle" : "Yeni Yatırımcı"}</SheetTitle>
|
<>
|
||||||
</SheetHeader>
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="grid gap-1.5">
|
||||||
<form action={formAction} className="mt-4 space-y-4 pb-6">
|
<Label htmlFor="name">Ad Soyad *</Label>
|
||||||
<div className="grid gap-1.5">
|
<Input id="name" name="name" defaultValue={investor?.name} placeholder="Mehmet Demir" />
|
||||||
<Label htmlFor="name">Ad Soyad *</Label>
|
{fe.name && <p className="text-destructive text-xs">{fe.name[0]}</p>}
|
||||||
<Input id="name" name="name" defaultValue={investor?.name} placeholder="Mehmet Demir" />
|
</div>
|
||||||
{fe.name && <p className="text-destructive text-xs">{fe.name[0]}</p>}
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="phone">Telefon</Label>
|
||||||
|
<Input id="phone" name="phone" type="tel" defaultValue={investor?.phone ?? ""} placeholder="+90 555 123 45 67" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="email">Email *</Label>
|
<Label htmlFor="email">Email *</Label>
|
||||||
<Input id="email" name="email" type="email" defaultValue={investor?.email} placeholder="mehmet@example.com" />
|
<Input id="email" name="email" type="email" defaultValue={investor?.email} placeholder="mehmet@example.com" />
|
||||||
{fe.email && <p className="text-destructive text-xs">{fe.email[0]}</p>}
|
{fe.email && <p className="text-destructive text-xs">{fe.email[0]}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Finansal",
|
||||||
|
content: (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="col-span-2 grid gap-1.5">
|
||||||
|
<Label htmlFor="budget">Bütçe</Label>
|
||||||
|
<Input id="budget" name="budget" type="number" min="0"
|
||||||
|
defaultValue={investor?.budget ?? ""} placeholder="5.000.000" />
|
||||||
|
</div>
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="phone">Telefon</Label>
|
<Label>Para birimi</Label>
|
||||||
<Input id="phone" name="phone" type="tel" defaultValue={investor?.phone ?? ""} placeholder="+90 555 123 45 67" />
|
<select name="currency" defaultValue={investor?.currency ?? "TRY"}
|
||||||
|
className="border-input bg-background h-9 rounded-md border px-3 text-sm">
|
||||||
|
<option value="TRY">TRY</option>
|
||||||
|
<option value="USD">USD</option>
|
||||||
|
<option value="EUR">EUR</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Not",
|
||||||
|
content: (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="notes">Notlar</Label>
|
||||||
|
<Textarea id="notes" name="notes" rows={5} defaultValue={investor?.notes ?? ""} placeholder="Yatırım tercihleri..." />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
return (
|
||||||
<div className="grid gap-1.5">
|
<ResponsiveSheet open={open} onOpenChange={onOpenChange}
|
||||||
<Label htmlFor="budget">Bütçe</Label>
|
title={investor ? "Yatırımcıyı Düzenle" : "Yeni Yatırımcı"}
|
||||||
<Input id="budget" name="budget" type="number" min="0" defaultValue={investor?.budget ?? ""} placeholder="5000000" />
|
maxWidth="sm:max-w-lg">
|
||||||
</div>
|
<form
|
||||||
<div className="grid gap-1.5">
|
action={formAction}
|
||||||
<Label>Para birimi</Label>
|
onKeyDown={(e) => {
|
||||||
<select
|
if (e.key === "Enter" && (e.target as HTMLElement).tagName !== "TEXTAREA") {
|
||||||
name="currency"
|
e.preventDefault();
|
||||||
defaultValue={investor?.currency ?? "TRY"}
|
}
|
||||||
className="border-input bg-background h-9 rounded-md border px-3 text-sm"
|
}}
|
||||||
>
|
>
|
||||||
<option value="TRY">TRY</option>
|
<FormWizard steps={steps} isPending={isPending} submitLabel={investor ? "Güncelle" : "Oluştur"} />
|
||||||
<option value="USD">USD</option>
|
</form>
|
||||||
<option value="EUR">EUR</option>
|
</ResponsiveSheet>
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<Label htmlFor="notes">Notlar</Label>
|
|
||||||
<Textarea id="notes" name="notes" rows={3} defaultValue={investor?.notes ?? ""} placeholder="Yatırım tercihleri..." />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SheetFooter>
|
|
||||||
<Button type="submit" disabled={isPending} className="w-full">
|
|
||||||
{isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
|
|
||||||
{investor ? "Güncelle" : "Oluştur"}
|
|
||||||
</Button>
|
|
||||||
</SheetFooter>
|
|
||||||
</form>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { MoreHorizontal, Plus, Pencil, Trash2 } from "lucide-react";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { DotsThree, Plus, PencilSimple, Trash } from '@/lib/icons';
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { deleteInvestorAction } from "@/lib/appwrite/investor-actions";
|
import { deleteInvestorAction } from "@/lib/appwrite/investor-actions";
|
||||||
import { InvestorFormSheet } from "./investor-form-sheet";
|
import { InvestorFormSheet } from "./investor-form-sheet";
|
||||||
|
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||||
import type { Investor } from "@/lib/appwrite/schema";
|
import type { Investor } from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
interface InvestorsClientProps {
|
interface InvestorsClientProps {
|
||||||
@@ -20,25 +22,21 @@ interface InvestorsClientProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function InvestorsClient({ initialInvestors }: InvestorsClientProps) {
|
export function InvestorsClient({ initialInvestors }: InvestorsClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
const [investors, setInvestors] = useState(initialInvestors);
|
const [investors, setInvestors] = useState(initialInvestors);
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Investor | null>(null);
|
const [editing, setEditing] = useState<Investor | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Investor | null>(null);
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() { setEditing(null); setSheetOpen(true); }
|
||||||
setEditing(null);
|
function openEdit(i: Investor) { setEditing(i); setSheetOpen(true); }
|
||||||
setSheetOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(i: Investor) {
|
async function doDelete() {
|
||||||
setEditing(i);
|
if (!deleteTarget) return;
|
||||||
setSheetOpen(true);
|
const result = await deleteInvestorAction(deleteTarget.$id);
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(i: Investor) {
|
|
||||||
if (!confirm(`"${i.name}" silinsin mi?`)) return;
|
|
||||||
const result = await deleteInvestorAction(i.$id);
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
setInvestors((prev) => prev.filter((x) => x.$id !== i.$id));
|
setInvestors((prev) => prev.filter((x) => x.$id !== deleteTarget.$id));
|
||||||
|
setDeleteTarget(null);
|
||||||
toast.success("Yatırımcı silindi.");
|
toast.success("Yatırımcı silindi.");
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error ?? "Silinemedi.");
|
toast.error(result.error ?? "Silinemedi.");
|
||||||
@@ -86,19 +84,19 @@ export function InvestorsClient({ initialInvestors }: InvestorsClientProps) {
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="size-8">
|
<Button variant="ghost" size="icon" className="size-8">
|
||||||
<MoreHorizontal className="size-4" />
|
<DotsThree className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => openEdit(i)}>
|
<DropdownMenuItem onClick={() => openEdit(i)}>
|
||||||
<Pencil className="mr-2 size-4" />
|
<PencilSimple className="mr-2 size-4" />
|
||||||
Düzenle
|
Düzenle
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleDelete(i)}
|
onClick={() => setDeleteTarget(i)}
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 size-4" />
|
<Trash className="mr-2 size-4" />
|
||||||
Sil
|
Sil
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -114,6 +112,14 @@ export function InvestorsClient({ initialInvestors }: InvestorsClientProps) {
|
|||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
onOpenChange={setSheetOpen}
|
onOpenChange={setSheetOpen}
|
||||||
investor={editing}
|
investor={editing}
|
||||||
|
onSuccess={() => router.refresh()}
|
||||||
|
/>
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(v) => { if (!v) setDeleteTarget(null); }}
|
||||||
|
title={`"${deleteTarget?.name}" silinsin mi?`}
|
||||||
|
description="Bu yatırımcı kalıcı olarak silinecek ve geri alınamaz."
|
||||||
|
onConfirm={doDelete}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,24 +2,24 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Shield,
|
Shield,
|
||||||
BarChart3,
|
ChartBar,
|
||||||
Database,
|
Database,
|
||||||
Building2,
|
Buildings,
|
||||||
Rocket,
|
Rocket,
|
||||||
Settings,
|
GearSix,
|
||||||
Zap,
|
Lightning,
|
||||||
Package,
|
Package,
|
||||||
Layout,
|
Layout,
|
||||||
Crown,
|
Crown,
|
||||||
Palette
|
Palette
|
||||||
} from 'lucide-react'
|
} from '@/lib/icons'
|
||||||
|
|
||||||
const menuSections = [
|
const menuSections = [
|
||||||
{
|
{
|
||||||
title: 'Browse Products',
|
title: 'Browse Products',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: 'Free Blocks',
|
title: 'Free SquaresFour',
|
||||||
description: 'Essential UI components and sections',
|
description: 'Essential UI components and sections',
|
||||||
icon: Package,
|
icon: Package,
|
||||||
href: '#free-blocks'
|
href: '#free-blocks'
|
||||||
@@ -33,7 +33,7 @@ const menuSections = [
|
|||||||
{
|
{
|
||||||
title: 'Admin Dashboards',
|
title: 'Admin Dashboards',
|
||||||
description: 'Full-featured dashboard solutions',
|
description: 'Full-featured dashboard solutions',
|
||||||
icon: BarChart3,
|
icon: ChartBar,
|
||||||
href: '#admin-dashboards'
|
href: '#admin-dashboards'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -50,7 +50,7 @@ const menuSections = [
|
|||||||
{
|
{
|
||||||
title: 'E-commerce',
|
title: 'E-commerce',
|
||||||
description: 'Online store admin panels and components',
|
description: 'Online store admin panels and components',
|
||||||
icon: Building2,
|
icon: Buildings,
|
||||||
href: '#ecommerce'
|
href: '#ecommerce'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -62,7 +62,7 @@ const menuSections = [
|
|||||||
{
|
{
|
||||||
title: 'Analytics',
|
title: 'Analytics',
|
||||||
description: 'Data visualization and reporting templates',
|
description: 'Data visualization and reporting templates',
|
||||||
icon: BarChart3,
|
icon: ChartBar,
|
||||||
href: '#analytics'
|
href: '#analytics'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -91,13 +91,13 @@ const menuSections = [
|
|||||||
{
|
{
|
||||||
title: 'GitHub Repository',
|
title: 'GitHub Repository',
|
||||||
description: 'Open source foundation and community',
|
description: 'Open source foundation and community',
|
||||||
icon: Settings,
|
icon: GearSix,
|
||||||
href: '#github'
|
href: '#github'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Design System',
|
title: 'Design System',
|
||||||
description: 'shadcn/ui standards and customization',
|
description: 'shadcn/ui standards and customization',
|
||||||
icon: Zap,
|
icon: Lightning,
|
||||||
href: '#design-system'
|
href: '#design-system'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { MapPin } from "lucide-react";
|
import { MapPin } from '@/lib/icons';
|
||||||
import type { Property } from "@/lib/appwrite/schema";
|
import type { Property } from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
const Inner = dynamic(
|
const Inner = dynamic(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import "maplibre-gl/dist/maplibre-gl.css";
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
import { Search, Loader2, MapPin, X } from "lucide-react";
|
import { MagnifyingGlass, CircleNotch, MapPin, X } from '@/lib/icons';
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
const STYLE_URL = "https://tiles.openfreemap.org/styles/bright";
|
const STYLE_URL = "https://tiles.openfreemap.org/styles/bright";
|
||||||
@@ -198,10 +198,10 @@ export function PropertyMapPickerInner({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Search input with autocomplete dropdown */}
|
{/* MagnifyingGlass input with autocomplete dropdown */}
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="text-muted-foreground absolute left-2.5 top-2.5 size-4" />
|
<MagnifyingGlass className="text-muted-foreground absolute left-2.5 top-2.5 size-4" />
|
||||||
<Input
|
<Input
|
||||||
className="pl-8 pr-8"
|
className="pl-8 pr-8"
|
||||||
placeholder="Adres, mahalle veya şehir ara..."
|
placeholder="Adres, mahalle veya şehir ara..."
|
||||||
@@ -211,7 +211,7 @@ export function PropertyMapPickerInner({
|
|||||||
onFocus={() => suggestions.length > 0 && setShowDropdown(true)}
|
onFocus={() => suggestions.length > 0 && setShowDropdown(true)}
|
||||||
/>
|
/>
|
||||||
{loading && (
|
{loading && (
|
||||||
<Loader2 className="text-muted-foreground absolute right-2.5 top-2.5 size-4 animate-spin" />
|
<CircleNotch className="text-muted-foreground absolute right-2.5 top-2.5 size-4 animate-spin" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -238,7 +238,7 @@ export function PropertyMapPickerInner({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Map */}
|
{/* MapTrifold */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="h-64 w-full overflow-hidden rounded-lg border"
|
className="h-64 w-full overflow-hidden rounded-lg border"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { MapPin } from "lucide-react";
|
import { MapPin } from '@/lib/icons';
|
||||||
|
|
||||||
const Inner = dynamic(
|
const Inner = dynamic(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { BellRinging, Checks } from '@/lib/icons';
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { MatchBreakdownDialog } from "./match-breakdown-dialog";
|
import { MatchBreakdownDialog } from "./match-breakdown-dialog";
|
||||||
|
import { markMatchNotifiedAction, markAllNotifiedAction } from "@/lib/appwrite/match-actions";
|
||||||
import type { Property, PropertyMatch, CustomerSearch, Customer } from "@/lib/appwrite/schema";
|
import type { Property, PropertyMatch, CustomerSearch, Customer } from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
|
type Tab = "pending" | "all";
|
||||||
|
|
||||||
interface MatchesClientProps {
|
interface MatchesClientProps {
|
||||||
matches: PropertyMatch[];
|
matches: PropertyMatch[];
|
||||||
customers: Customer[];
|
customers: Customer[];
|
||||||
@@ -14,87 +22,187 @@ interface MatchesClientProps {
|
|||||||
function ScoreBadge({ score }: { score?: number | null }) {
|
function ScoreBadge({ score }: { score?: number | null }) {
|
||||||
const s = score ?? 0;
|
const s = score ?? 0;
|
||||||
const color =
|
const color =
|
||||||
s >= 80
|
s >= 80 ? "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300"
|
||||||
? "bg-green-100 text-green-700"
|
: s >= 60 ? "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"
|
||||||
: s >= 60
|
: s >= 40 ? "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300"
|
||||||
? "bg-blue-100 text-blue-700"
|
: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400";
|
||||||
: s >= 40
|
|
||||||
? "bg-yellow-100 text-yellow-700"
|
|
||||||
: "bg-gray-100 text-gray-500";
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${color}`}>
|
||||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${color}`}
|
|
||||||
>
|
|
||||||
{s}
|
{s}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MatchesClient({ matches, customers, properties, searches }: MatchesClientProps) {
|
export function MatchesClient({ matches, customers, properties, searches }: MatchesClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [items, setItems] = useState(matches);
|
||||||
|
const [tab, setTab] = useState<Tab>("pending");
|
||||||
const [selectedMatch, setSelectedMatch] = useState<PropertyMatch | null>(null);
|
const [selectedMatch, setSelectedMatch] = useState<PropertyMatch | null>(null);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [notifyingId, setNotifyingId] = useState<string | null>(null);
|
||||||
|
const [notifyingAll, setNotifyingAll] = useState(false);
|
||||||
|
|
||||||
const customerMap = Object.fromEntries(customers.map((c) => [c.$id, c]));
|
const customerMap = Object.fromEntries(customers.map((c) => [c.$id, c]));
|
||||||
const propertyMap = Object.fromEntries(properties.map((p) => [p.$id, p]));
|
const propertyMap = Object.fromEntries(properties.map((p) => [p.$id, p]));
|
||||||
const searchMap = Object.fromEntries(searches.map((s) => [s.$id, s]));
|
const searchMap = Object.fromEntries(searches.map((s) => [s.$id, s]));
|
||||||
|
|
||||||
|
const pendingCount = items.filter((m) => !m.notified).length;
|
||||||
|
const visible = tab === "pending" ? items.filter((m) => !m.notified) : items;
|
||||||
|
|
||||||
function openBreakdown(m: PropertyMatch) {
|
function openBreakdown(m: PropertyMatch) {
|
||||||
setSelectedMatch(m);
|
setSelectedMatch(m);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleNotify(m: PropertyMatch) {
|
||||||
|
setNotifyingId(m.$id);
|
||||||
|
const result = await markMatchNotifiedAction(m.$id);
|
||||||
|
setNotifyingId(null);
|
||||||
|
if (result.ok) {
|
||||||
|
setItems((prev) => prev.map((x) => x.$id === m.$id ? { ...x, notified: true } : x));
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error ?? "Güncelleme başarısız.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNotifyAll() {
|
||||||
|
const pendingIds = items.filter((m) => !m.notified).map((m) => m.$id);
|
||||||
|
if (pendingIds.length === 0) return;
|
||||||
|
setNotifyingAll(true);
|
||||||
|
const result = await markAllNotifiedAction(pendingIds);
|
||||||
|
setNotifyingAll(false);
|
||||||
|
if (result.ok) {
|
||||||
|
setItems((prev) => prev.map((x) => ({ ...x, notified: true })));
|
||||||
|
router.refresh();
|
||||||
|
toast.success(`${pendingIds.length} eşleşme bildirildi olarak işaretlendi.`);
|
||||||
|
} else {
|
||||||
|
toast.error(result.error ?? "Toplu güncelleme başarısız.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-1 flex-col gap-4">
|
<div className="flex flex-1 flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
{/* Header */}
|
||||||
<h1 className="text-2xl font-bold">Eşleşmeler</h1>
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
<span className="text-muted-foreground text-sm">{matches.length} eşleşme</span>
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Eşleşmeler</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
{pendingCount > 0
|
||||||
|
? `${pendingCount} bildirilmemiş eşleşme`
|
||||||
|
: "Tüm eşleşmeler bildirildi"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<Button size="sm" variant="outline" onClick={handleNotifyAll} disabled={notifyingAll}>
|
||||||
|
<Checks className="mr-1.5 size-4" />
|
||||||
|
Tümünü bildirildi işaretle
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 border-b">
|
||||||
|
{(["pending", "all"] as Tab[]).map((key) => {
|
||||||
|
const label = key === "pending" ? "Bekleyen" : "Tümü";
|
||||||
|
const count = key === "pending" ? pendingCount : items.length;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(key)}
|
||||||
|
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||||
|
tab === key
|
||||||
|
? "border-primary text-foreground"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<span className={`ml-1.5 text-xs rounded-full px-1.5 py-0.5 ${
|
||||||
|
tab === key ? "bg-primary/20 text-primary" : "bg-muted text-muted-foreground"
|
||||||
|
}`}>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b">
|
<tr className="border-b bg-muted/30">
|
||||||
<th className="p-3 text-left font-medium">Puan</th>
|
<th className="p-3 text-left font-medium">Puan</th>
|
||||||
<th className="p-3 text-left font-medium">Müşteri</th>
|
<th className="p-3 text-left font-medium">Müşteri</th>
|
||||||
<th className="p-3 text-left font-medium">İlan</th>
|
<th className="p-3 text-left font-medium">İlan</th>
|
||||||
<th className="p-3 text-left font-medium">Tarih</th>
|
<th className="p-3 text-left font-medium hidden md:table-cell">Tarih</th>
|
||||||
<th className="p-3 text-left font-medium">Görüntülendi</th>
|
<th className="p-3 text-left font-medium">Durum</th>
|
||||||
|
<th className="p-3" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{matches.length === 0 && (
|
{visible.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="text-muted-foreground py-10 text-center">
|
<td colSpan={6} className="text-muted-foreground py-10 text-center">
|
||||||
Henüz eşleşme yok.
|
{tab === "pending" ? "Bekleyen eşleşme yok." : "Henüz eşleşme yok."}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{matches.map((m) => {
|
{visible.map((m) => {
|
||||||
const customer = customerMap[m.customerId];
|
const customer = customerMap[m.customerId];
|
||||||
const property = propertyMap[m.propertyId];
|
const property = propertyMap[m.propertyId];
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={m.$id}
|
key={m.$id}
|
||||||
className="hover:bg-muted/30 cursor-pointer border-b last:border-0"
|
className={`border-b last:border-0 ${
|
||||||
onClick={() => openBreakdown(m)}
|
!m.notified ? "bg-amber-50/40 dark:bg-amber-950/10" : "hover:bg-muted/30"
|
||||||
title="Eşleşme kırılımını görmek için tıklayın"
|
}`}
|
||||||
>
|
>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<ScoreBadge score={m.score} />
|
<ScoreBadge score={m.score} />
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">{customer?.name ?? m.customerId}</td>
|
<td className="p-3 cursor-pointer" onClick={() => openBreakdown(m)}>
|
||||||
<td className="p-3">{property?.title ?? m.propertyId}</td>
|
<p className="font-medium">{customer?.name ?? m.customerId}</p>
|
||||||
<td className="p-3 text-muted-foreground">
|
{customer?.phone && (
|
||||||
|
<p className="text-xs text-muted-foreground">{customer.phone}</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 cursor-pointer max-w-[180px]" onClick={() => openBreakdown(m)}>
|
||||||
|
<p className="truncate">{property?.title ?? m.propertyId}</p>
|
||||||
|
{property?.city && (
|
||||||
|
<p className="text-xs text-muted-foreground">{property.city}</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-muted-foreground hidden md:table-cell">
|
||||||
{new Date(m.$createdAt).toLocaleDateString("tr-TR")}
|
{new Date(m.$createdAt).toLocaleDateString("tr-TR")}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
{m.viewedAt ? (
|
{m.notified ? (
|
||||||
<span className="text-xs text-green-600">
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
{new Date(m.viewedAt).toLocaleDateString("tr-TR")}
|
<Checks className="size-3.5 text-green-500" />
|
||||||
|
Bildirildi
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground text-xs">Hayır</span>
|
<span className="text-xs text-amber-600 font-medium flex items-center gap-1">
|
||||||
|
<BellRinging className="size-3.5" />
|
||||||
|
Bekliyor
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
{!m.notified && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
disabled={notifyingId === m.$id}
|
||||||
|
onClick={() => handleNotify(m)}
|
||||||
|
>
|
||||||
|
<BellRinging className="size-3 mr-1" />
|
||||||
|
Bildir
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Moon, Sun } from "lucide-react"
|
import { Moon, Sun } from '@/lib/icons'
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { useTheme } from "@/hooks/use-theme"
|
import { useTheme } from "@/hooks/use-theme"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { ChevronRight, type LucideIcon } from "lucide-react"
|
import { CaretRight, type Icon } from '@/lib/icons'
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
|
|
||||||
@@ -28,12 +28,13 @@ export function NavMain({
|
|||||||
items: {
|
items: {
|
||||||
title: string
|
title: string
|
||||||
url: string
|
url: string
|
||||||
icon?: LucideIcon
|
icon?: Icon
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
items?: {
|
items?: {
|
||||||
title: string
|
title: string
|
||||||
url: string
|
url: string
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
|
badge?: number
|
||||||
}[]
|
}[]
|
||||||
}[]
|
}[]
|
||||||
}) {
|
}) {
|
||||||
@@ -63,7 +64,7 @@ export function NavMain({
|
|||||||
<SidebarMenuButton tooltip={item.title} className="cursor-pointer">
|
<SidebarMenuButton tooltip={item.title} className="cursor-pointer">
|
||||||
{item.icon && <item.icon />}
|
{item.icon && <item.icon />}
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
<CaretRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
@@ -77,6 +78,11 @@ export function NavMain({
|
|||||||
rel={(item.title === "Auth Pages" || item.title === "Errors") ? "noopener noreferrer" : undefined}
|
rel={(item.title === "Auth Pages" || item.title === "Errors") ? "noopener noreferrer" : undefined}
|
||||||
>
|
>
|
||||||
<span>{subItem.title}</span>
|
<span>{subItem.title}</span>
|
||||||
|
{subItem.badge != null && subItem.badge > 0 && (
|
||||||
|
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-destructive-foreground">
|
||||||
|
{subItem.badge > 99 ? "99+" : subItem.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuSubButton>
|
</SidebarMenuSubButton>
|
||||||
</SidebarMenuSubItem>
|
</SidebarMenuSubItem>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { type LucideIcon } from "lucide-react"
|
import { type Icon } from '@/lib/icons'
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +17,7 @@ export function NavSecondary({
|
|||||||
items: {
|
items: {
|
||||||
title: string
|
title: string
|
||||||
url: string
|
url: string
|
||||||
icon: LucideIcon
|
icon: Icon
|
||||||
}[]
|
}[]
|
||||||
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
|
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
import { useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
import {
|
import {
|
||||||
BellDot,
|
BellSimple,
|
||||||
CircleUser,
|
UserCircle,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
EllipsisVertical,
|
DotsThreeVertical,
|
||||||
LogOut,
|
SignOut,
|
||||||
} from "lucide-react";
|
} from '@/lib/icons';
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -62,7 +62,7 @@ export function NavUser({
|
|||||||
<span className="truncate font-medium">{user.name}</span>
|
<span className="truncate font-medium">{user.name}</span>
|
||||||
<span className="text-muted-foreground truncate text-xs">{user.email}</span>
|
<span className="text-muted-foreground truncate text-xs">{user.email}</span>
|
||||||
</div>
|
</div>
|
||||||
<EllipsisVertical className="ml-auto size-4" />
|
<DotsThreeVertical className="ml-auto size-4" />
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
@@ -86,7 +86,7 @@ export function NavUser({
|
|||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem asChild className="cursor-pointer">
|
<DropdownMenuItem asChild className="cursor-pointer">
|
||||||
<Link href="/settings/account">
|
<Link href="/settings/account">
|
||||||
<CircleUser />
|
<UserCircle />
|
||||||
Profil
|
Profil
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -98,7 +98,7 @@ export function NavUser({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild className="cursor-pointer">
|
<DropdownMenuItem asChild className="cursor-pointer">
|
||||||
<Link href="/settings/notifications">
|
<Link href="/settings/notifications">
|
||||||
<BellDot />
|
<BellSimple />
|
||||||
Bildirimler
|
Bildirimler
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -109,7 +109,7 @@ export function NavUser({
|
|||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
<LogOut />
|
<SignOut />
|
||||||
{isPending ? "Çıkış yapılıyor..." : "Çıkış yap"}
|
{isPending ? "Çıkış yapılıyor..." : "Çıkış yap"}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState, useEffect } from "react";
|
import { useActionState, useEffect } from "react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { CircleNotch } from '@/lib/icons';
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -9,13 +9,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetFooter,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from "@/components/ui/sheet";
|
|
||||||
import {
|
import {
|
||||||
createPresentationAction,
|
createPresentationAction,
|
||||||
updatePresentationAction,
|
updatePresentationAction,
|
||||||
@@ -65,66 +59,61 @@ export function PresentationFormSheet({
|
|||||||
const fe = state.fieldErrors ?? {};
|
const fe = state.fieldErrors ?? {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<ResponsiveSheet
|
||||||
<SheetContent className="sm:max-w-md overflow-y-auto">
|
open={open}
|
||||||
<SheetHeader>
|
onOpenChange={onOpenChange}
|
||||||
<SheetTitle>{presentation ? "Sunumu Düzenle" : "Yeni Sunum"}</SheetTitle>
|
title={presentation ? "Sunumu Düzenle" : "Yeni Sunum"}
|
||||||
</SheetHeader>
|
>
|
||||||
|
<form action={formAction} className="space-y-4 pb-2">
|
||||||
|
<div data-tour="form-presentations-title" className="grid gap-1.5">
|
||||||
|
<Label htmlFor="title">Sunum başlığı *</Label>
|
||||||
|
<Input id="title" name="title" defaultValue={presentation?.title} placeholder="Kadıköy 3+1 Seçenekleri" />
|
||||||
|
{fe.title && <p className="text-destructive text-xs">{fe.title[0]}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
<form action={formAction} className="mt-4 space-y-4 pb-6">
|
<div className="grid gap-1.5">
|
||||||
<div className="grid gap-1.5">
|
<Label>Müşteri</Label>
|
||||||
<Label htmlFor="title">Sunum başlığı *</Label>
|
<select
|
||||||
<Input id="title" name="title" defaultValue={presentation?.title} placeholder="Kadıköy 3+1 Seçenekleri" />
|
name="customerId"
|
||||||
{fe.title && <p className="text-destructive text-xs">{fe.title[0]}</p>}
|
defaultValue={presentation?.customerId ?? ""}
|
||||||
</div>
|
className="border-input bg-background h-9 rounded-md border px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Müşteri seçin (opsiyonel)</option>
|
||||||
|
{customers.map((c) => (
|
||||||
|
<option key={c.$id} value={c.$id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-1.5">
|
<div data-tour="form-presentations-properties" className="grid gap-1.5">
|
||||||
<Label>Müşteri</Label>
|
<Label>İlanlar *</Label>
|
||||||
<select
|
{fe.propertyIds && <p className="text-destructive text-xs">{fe.propertyIds[0]}</p>}
|
||||||
name="customerId"
|
<PropertyCheckboxList properties={properties} selectedIds={selectedIds} />
|
||||||
defaultValue={presentation?.customerId ?? ""}
|
</div>
|
||||||
className="border-input bg-background h-9 rounded-md border px-3 text-sm"
|
|
||||||
>
|
|
||||||
<option value="">Müşteri seçin (opsiyonel)</option>
|
|
||||||
{customers.map((c) => (
|
|
||||||
<option key={c.$id} value={c.$id}>{c.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label>İlanlar *</Label>
|
<Label htmlFor="expiresAt">Geçerlilik tarihi</Label>
|
||||||
{fe.propertyIds && <p className="text-destructive text-xs">{fe.propertyIds[0]}</p>}
|
<Input
|
||||||
<PropertyCheckboxList
|
id="expiresAt"
|
||||||
properties={properties}
|
name="expiresAt"
|
||||||
selectedIds={selectedIds}
|
type="date"
|
||||||
/>
|
defaultValue={presentation?.expiresAt ? presentation.expiresAt.split("T")[0] : ""}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="expiresAt">Geçerlilik tarihi</Label>
|
<Label htmlFor="notes">Notlar</Label>
|
||||||
<Input
|
<Textarea id="notes" name="notes" rows={2} defaultValue={presentation?.notes ?? ""} />
|
||||||
id="expiresAt"
|
</div>
|
||||||
name="expiresAt"
|
|
||||||
type="date"
|
|
||||||
defaultValue={presentation?.expiresAt ? presentation.expiresAt.split("T")[0] : ""}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-1.5">
|
<div className="pt-2">
|
||||||
<Label htmlFor="notes">Notlar</Label>
|
<Button type="submit" disabled={isPending} className="w-full">
|
||||||
<Textarea id="notes" name="notes" rows={2} defaultValue={presentation?.notes ?? ""} />
|
{isPending && <CircleNotch className="mr-2 size-4 animate-spin" />}
|
||||||
</div>
|
{presentation ? "Güncelle" : "Oluştur"}
|
||||||
|
</Button>
|
||||||
<SheetFooter>
|
</div>
|
||||||
<Button type="submit" disabled={isPending} className="w-full">
|
</form>
|
||||||
{isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
|
</ResponsiveSheet>
|
||||||
{presentation ? "Güncelle" : "Oluştur"}
|
|
||||||
</Button>
|
|
||||||
</SheetFooter>
|
|
||||||
</form>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,8 +124,6 @@ function PropertyCheckboxList({
|
|||||||
properties: Property[];
|
properties: Property[];
|
||||||
selectedIds: string[];
|
selectedIds: string[];
|
||||||
}) {
|
}) {
|
||||||
// We encode selected propertyIds as a JSON array in a hidden input
|
|
||||||
// and use checkboxes with the same name to collect values
|
|
||||||
return (
|
return (
|
||||||
<div className="max-h-48 overflow-y-auto rounded-md border p-2 space-y-1">
|
<div className="max-h-48 overflow-y-auto rounded-md border p-2 space-y-1">
|
||||||
{properties.length === 0 && (
|
{properties.length === 0 && (
|
||||||
@@ -154,7 +141,6 @@ function PropertyCheckboxList({
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
{/* Hidden field placeholder — server action reads propertyIdsRaw[] and joins them */}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { MoreHorizontal, Plus, Pencil, Trash2, ExternalLink, Copy } from "lucide-react";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { DotsThree, Plus, PencilSimple, Trash, ArrowSquareOut, Copy } from '@/lib/icons';
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { deletePresentationAction } from "@/lib/appwrite/presentation-actions";
|
import { deletePresentationAction } from "@/lib/appwrite/presentation-actions";
|
||||||
import { PresentationFormSheet } from "./presentation-form-sheet";
|
import { PresentationFormSheet } from "./presentation-form-sheet";
|
||||||
|
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||||
import type { Customer, Presentation, Property } from "@/lib/appwrite/schema";
|
import type { Customer, Presentation, Property } from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
interface PresentationsClientProps {
|
interface PresentationsClientProps {
|
||||||
@@ -26,9 +28,11 @@ export function PresentationsClient({
|
|||||||
customers,
|
customers,
|
||||||
properties,
|
properties,
|
||||||
}: PresentationsClientProps) {
|
}: PresentationsClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
const [presentations, setPresentations] = useState(initialPresentations);
|
const [presentations, setPresentations] = useState(initialPresentations);
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Presentation | null>(null);
|
const [editing, setEditing] = useState<Presentation | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Presentation | null>(null);
|
||||||
|
|
||||||
const appUrl = typeof window !== "undefined" ? window.location.origin : "";
|
const appUrl = typeof window !== "undefined" ? window.location.origin : "";
|
||||||
|
|
||||||
@@ -49,26 +53,31 @@ export function PresentationsClient({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
useEffect(() => {
|
||||||
setEditing(null);
|
const open = () => { setEditing(null); setSheetOpen(true); };
|
||||||
setSheetOpen(true);
|
const close = () => setSheetOpen(false);
|
||||||
}
|
window.addEventListener("kovak:open-form-presentations", open);
|
||||||
|
window.addEventListener("kovak:close-form-presentations", close);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("kovak:open-form-presentations", open);
|
||||||
|
window.removeEventListener("kovak:close-form-presentations", close);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
function openEdit(p: Presentation) {
|
function openCreate() { setEditing(null); setSheetOpen(true); }
|
||||||
setEditing(p);
|
function openEdit(p: Presentation) { setEditing(p); setSheetOpen(true); }
|
||||||
setSheetOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyLink(p: Presentation) {
|
async function copyLink(p: Presentation) {
|
||||||
await navigator.clipboard.writeText(getShareUrl(p));
|
await navigator.clipboard.writeText(getShareUrl(p));
|
||||||
toast.success("Link kopyalandı.");
|
toast.success("Link kopyalandı.");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(p: Presentation) {
|
async function doDelete() {
|
||||||
if (!confirm(`"${p.title}" sunumu silinsin mi?`)) return;
|
if (!deleteTarget) return;
|
||||||
const result = await deletePresentationAction(p.$id);
|
const result = await deletePresentationAction(deleteTarget.$id);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
setPresentations((prev) => prev.filter((x) => x.$id !== p.$id));
|
setPresentations((prev) => prev.filter((x) => x.$id !== deleteTarget.$id));
|
||||||
|
setDeleteTarget(null);
|
||||||
toast.success("Sunum silindi.");
|
toast.success("Sunum silindi.");
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error ?? "Silinemedi.");
|
toast.error(result.error ?? "Silinemedi.");
|
||||||
@@ -79,13 +88,13 @@ export function PresentationsClient({
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Sunumlar</h1>
|
<h1 className="text-2xl font-bold">Sunumlar</h1>
|
||||||
<Button onClick={openCreate} size="sm">
|
<Button onClick={openCreate} size="sm" data-tour="presentations-add">
|
||||||
<Plus className="mr-1.5 size-4" />
|
<Plus className="mr-1.5 size-4" />
|
||||||
Yeni Sunum
|
Yeni Sunum
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border">
|
<div data-tour="presentations-table" className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -118,7 +127,7 @@ export function PresentationsClient({
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="size-8">
|
<Button variant="ghost" size="icon" className="size-8">
|
||||||
<MoreHorizontal className="size-4" />
|
<DotsThree className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
@@ -128,19 +137,19 @@ export function PresentationsClient({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<a href={getShareUrl(p)} target="_blank" rel="noopener noreferrer">
|
<a href={getShareUrl(p)} target="_blank" rel="noopener noreferrer">
|
||||||
<ExternalLink className="mr-2 size-4" />
|
<ArrowSquareOut className="mr-2 size-4" />
|
||||||
Önizleme
|
Önizleme
|
||||||
</a>
|
</a>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => openEdit(p)}>
|
<DropdownMenuItem onClick={() => openEdit(p)}>
|
||||||
<Pencil className="mr-2 size-4" />
|
<PencilSimple className="mr-2 size-4" />
|
||||||
Düzenle
|
Düzenle
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleDelete(p)}
|
onClick={() => setDeleteTarget(p)}
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 size-4" />
|
<Trash className="mr-2 size-4" />
|
||||||
Sil
|
Sil
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -158,6 +167,14 @@ export function PresentationsClient({
|
|||||||
presentation={editing}
|
presentation={editing}
|
||||||
customers={customers}
|
customers={customers}
|
||||||
properties={properties}
|
properties={properties}
|
||||||
|
onSuccess={() => router.refresh()}
|
||||||
|
/>
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(v) => { if (!v) setDeleteTarget(null); }}
|
||||||
|
title={`"${deleteTarget?.title}" sunumu silinsin mi?`}
|
||||||
|
description="Sunum ve paylaşım linki kalıcı olarak silinecek."
|
||||||
|
onConfirm={doDelete}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Sparkles, Check } from "lucide-react"
|
import { Sparkle, Check } from '@/lib/icons'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export interface PricingPlan {
|
export interface PricingPlan {
|
||||||
@@ -114,7 +114,7 @@ export function PricingPlans({
|
|||||||
{tier.popular && (
|
{tier.popular && (
|
||||||
<div className='absolute start-0 -top-3 w-full'>
|
<div className='absolute start-0 -top-3 w-full'>
|
||||||
<Badge className='mx-auto flex w-fit gap-1.5 rounded-full font-medium'>
|
<Badge className='mx-auto flex w-fit gap-1.5 rounded-full font-medium'>
|
||||||
<Sparkles className='!size-4' />
|
<Sparkle className='!size-4' />
|
||||||
{mode === 'pricing' && (
|
{mode === 'pricing' && (
|
||||||
<span>Most Popular</span>
|
<span>Most Popular</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { X, CaretLeft, CaretRight } from "@/lib/icons";
|
||||||
|
import { getPropertyImageUrl, getPropertyImagePreviewUrl } from "@/lib/appwrite/storage-utils";
|
||||||
|
|
||||||
|
interface ImageLightboxProps {
|
||||||
|
imageIds: string[];
|
||||||
|
title: string;
|
||||||
|
initialIndex?: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageLightbox({ imageIds, title, initialIndex = 0, onClose }: ImageLightboxProps) {
|
||||||
|
const [idx, setIdx] = useState(initialIndex);
|
||||||
|
const prev = useCallback(() => setIdx((i) => (i - 1 + imageIds.length) % imageIds.length), [imageIds.length]);
|
||||||
|
const next = useCallback(() => setIdx((i) => (i + 1) % imageIds.length), [imageIds.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
if (e.key === "ArrowLeft") prev();
|
||||||
|
if (e.key === "ArrowRight") next();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, [onClose, prev, next]);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{/* Kapat */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute top-4 right-4 size-9 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-white transition-colors"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Kapat"
|
||||||
|
>
|
||||||
|
<X className="size-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Sayaç */}
|
||||||
|
<div className="absolute top-4 left-1/2 -translate-x-1/2 text-white/70 text-sm tabular-nums select-none">
|
||||||
|
{idx + 1} / {imageIds.length}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Önceki */}
|
||||||
|
{imageIds.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); prev(); }}
|
||||||
|
className="absolute left-3 sm:left-6 size-10 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-white transition-colors"
|
||||||
|
aria-label="Önceki"
|
||||||
|
>
|
||||||
|
<CaretLeft className="size-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ana görsel — orijinal boyut, kırpma yok */}
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={getPropertyImageUrl(imageIds[idx])}
|
||||||
|
alt={`${title} ${idx + 1}`}
|
||||||
|
className="max-h-[90dvh] max-w-[90vw] object-contain rounded-lg select-none"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sonraki */}
|
||||||
|
{imageIds.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); next(); }}
|
||||||
|
className="absolute right-3 sm:right-6 size-10 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-white transition-colors"
|
||||||
|
aria-label="Sonraki"
|
||||||
|
>
|
||||||
|
<CaretRight className="size-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Thumbnail şeridi */}
|
||||||
|
{imageIds.length > 1 && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-1.5 overflow-x-auto max-w-[90vw] px-2 scrollbar-hide"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{imageIds.map((id, i) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIdx(i)}
|
||||||
|
className={`shrink-0 w-14 h-10 rounded overflow-hidden border-2 transition-all ${
|
||||||
|
i === idx ? "border-white" : "border-white/20 hover:border-white/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={getPropertyImagePreviewUrl(id, 160, 120)}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef } from "react";
|
import React, { useState, useRef, useMemo, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { MoreHorizontal, Plus, Pencil, Trash2, ExternalLink, List, Map } from "lucide-react";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { DotsThree, Plus, PencilSimple, Trash, ArrowSquareOut, List, MapTrifold, MagnifyingGlass, X, ImageSquare, SquaresFour, CaretLeft, CaretRight } from '@/lib/icons';
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -15,9 +16,11 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { deletePropertyAction } from "@/lib/appwrite/property-actions";
|
import { deletePropertyAction } from "@/lib/appwrite/property-actions";
|
||||||
import { PropertyFormSheet } from "./property-form-sheet";
|
import { PropertyFormSheet } from "./property-form-sheet";
|
||||||
|
import { ImageLightbox } from "./image-lightbox";
|
||||||
|
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||||
import { PropertiesMapView } from "@/components/map/properties-map-view";
|
import { PropertiesMapView } from "@/components/map/properties-map-view";
|
||||||
import { getPropertyImagePreviewUrl, parseImageIds } from "@/lib/appwrite/storage-utils";
|
import { getPropertyImagePreviewUrl, parseImageIds } from "@/lib/appwrite/storage-utils";
|
||||||
import type { Property } from "@/lib/appwrite/schema";
|
import type { Property, PropertyStatus } from "@/lib/appwrite/schema";
|
||||||
import {
|
import {
|
||||||
PROPERTY_STATUS_LABELS,
|
PROPERTY_STATUS_LABELS,
|
||||||
PROPERTY_TYPE_LABELS,
|
PROPERTY_TYPE_LABELS,
|
||||||
@@ -28,25 +31,41 @@ interface PropertiesClientProps {
|
|||||||
initialProperties: Property[];
|
initialProperties: Property[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewMode = "list" | "map";
|
type ViewMode = "list" | "gallery" | "map";
|
||||||
|
|
||||||
export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
|
export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
const [properties, setProperties] = useState(initialProperties);
|
const [properties, setProperties] = useState(initialProperties);
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Property | null>(null);
|
const [editing, setEditing] = useState<Property | null>(null);
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Property | null>(null);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<PropertyStatus | "all">("all");
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const rowRefs = useRef<Record<string, HTMLTableRowElement>>({});
|
const rowRefs = useRef<Record<string, HTMLTableRowElement>>({});
|
||||||
const cardRefs = useRef<Record<string, HTMLDivElement>>({});
|
const cardRefs = useRef<Record<string, HTMLDivElement>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const open = () => { setEditing(null); setSheetOpen(true); };
|
||||||
|
const close = () => setSheetOpen(false);
|
||||||
|
window.addEventListener("kovak:open-form-properties", open);
|
||||||
|
window.addEventListener("kovak:close-form-properties", close);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("kovak:open-form-properties", open);
|
||||||
|
window.removeEventListener("kovak:close-form-properties", close);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
function openCreate() { setEditing(null); setSheetOpen(true); }
|
function openCreate() { setEditing(null); setSheetOpen(true); }
|
||||||
function openEdit(p: Property) { setEditing(p); setSheetOpen(true); }
|
function openEdit(p: Property) { setEditing(p); setSheetOpen(true); }
|
||||||
|
|
||||||
async function handleDelete(p: Property) {
|
async function doDelete() {
|
||||||
if (!confirm(`"${p.title}" silinsin mi?`)) return;
|
if (!deleteTarget) return;
|
||||||
const result = await deletePropertyAction(p.$id);
|
const result = await deletePropertyAction(deleteTarget.$id);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
setProperties((prev) => prev.filter((x) => x.$id !== p.$id));
|
setProperties((prev) => prev.filter((x) => x.$id !== deleteTarget.$id));
|
||||||
|
setDeleteTarget(null);
|
||||||
toast.success("İlan silindi.");
|
toast.success("İlan silindi.");
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error ?? "Silinemedi.");
|
toast.error(result.error ?? "Silinemedi.");
|
||||||
@@ -64,7 +83,35 @@ export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
|
|||||||
setSelectedId(id === selectedId ? null : id);
|
setSelectedId(id === selectedId ? null : id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mappedCount = properties.filter((p) => p.mapLat != null && p.mapLng != null).length;
|
const filteredProperties = useMemo(() => {
|
||||||
|
let list = properties;
|
||||||
|
if (statusFilter !== "all") list = list.filter((p) => p.status === statusFilter);
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
list = list.filter((p) =>
|
||||||
|
p.title.toLowerCase().includes(q) ||
|
||||||
|
(p.city ?? "").toLowerCase().includes(q) ||
|
||||||
|
(p.district ?? "").toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [properties, statusFilter, searchQuery]);
|
||||||
|
|
||||||
|
const mappedCount = filteredProperties.filter((p) => p.mapLat != null && p.mapLng != null).length;
|
||||||
|
|
||||||
|
const { withImages, withoutImages } = useMemo(() => ({
|
||||||
|
withImages: filteredProperties.filter((p) => parseImageIds(p.imageIds).length > 0),
|
||||||
|
withoutImages: filteredProperties.filter((p) => parseImageIds(p.imageIds).length === 0),
|
||||||
|
}), [filteredProperties]);
|
||||||
|
|
||||||
|
const STATUS_TABS: Array<{ key: PropertyStatus | "all"; label: string }> = [
|
||||||
|
{ key: "all", label: "Tümü" },
|
||||||
|
{ key: "aktif", label: "Aktif" },
|
||||||
|
{ key: "rezerve", label: "Rezerve" },
|
||||||
|
{ key: "pasif", label: "Pasif" },
|
||||||
|
{ key: "satildi", label: "Satıldı" },
|
||||||
|
{ key: "kiralandit", label: "Kiralandı" },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 flex-1">
|
<div className="flex flex-col gap-4 flex-1">
|
||||||
@@ -72,121 +119,190 @@ export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
|
|||||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">İlanlar</h1>
|
<h1 className="text-2xl font-bold">İlanlar</h1>
|
||||||
{viewMode === "map" && mappedCount < properties.length && (
|
{viewMode === "map" && mappedCount < filteredProperties.length && (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{mappedCount} / {properties.length} ilanın koordinatı var
|
{mappedCount} / {filteredProperties.length} ilanın koordinatı var
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* View toggle */}
|
{/* View toggle */}
|
||||||
<div className="flex rounded-md border overflow-hidden">
|
<div className="flex rounded-md border overflow-hidden">
|
||||||
<button
|
{([
|
||||||
type="button"
|
{ mode: "list" as ViewMode, icon: List, label: "Liste" },
|
||||||
onClick={() => setViewMode("list")}
|
{ mode: "gallery" as ViewMode, icon: SquaresFour, label: "Galeri" },
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm transition-colors ${
|
{ mode: "map" as ViewMode, icon: MapTrifold, label: "Harita" },
|
||||||
viewMode === "list"
|
]).map(({ mode, icon: Icon, label }, i) => (
|
||||||
? "bg-primary text-primary-foreground"
|
<button
|
||||||
: "bg-background text-muted-foreground hover:text-foreground"
|
key={mode}
|
||||||
}`}
|
type="button"
|
||||||
>
|
onClick={() => setViewMode(mode)}
|
||||||
<List className="size-3.5" />
|
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm transition-colors ${i > 0 ? "border-l" : ""} ${
|
||||||
Liste
|
viewMode === mode
|
||||||
</button>
|
? "bg-primary text-primary-foreground"
|
||||||
<button
|
: "bg-background text-muted-foreground hover:text-foreground"
|
||||||
type="button"
|
}`}
|
||||||
onClick={() => setViewMode("map")}
|
>
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm transition-colors border-l ${
|
<Icon className="size-3.5" />
|
||||||
viewMode === "map"
|
<span className="hidden sm:inline">{label}</span>
|
||||||
? "bg-primary text-primary-foreground"
|
</button>
|
||||||
: "bg-background text-muted-foreground hover:text-foreground"
|
))}
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Map className="size-3.5" />
|
|
||||||
Harita
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={openCreate} size="sm">
|
<Button onClick={openCreate} size="sm" data-tour="properties-add">
|
||||||
<Plus className="mr-1.5 size-4" />
|
<Plus className="mr-1.5 size-4" />
|
||||||
Yeni İlan
|
Yeni İlan
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Filter bar */}
|
||||||
|
<div data-tour="properties-filters" className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
{/* Status tabs */}
|
||||||
|
<div className="flex overflow-x-auto gap-1 shrink-0">
|
||||||
|
{STATUS_TABS.map(({ key, label }) => {
|
||||||
|
const count = key === "all" ? properties.length : properties.filter((p) => p.status === key).length;
|
||||||
|
if (key !== "all" && count === 0) return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatusFilter(key)}
|
||||||
|
className={`shrink-0 px-3 py-1 text-xs rounded-full font-medium transition-colors ${
|
||||||
|
statusFilter === key
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label} <span className="opacity-70">{count}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* MagnifyingGlass */}
|
||||||
|
<div className="relative sm:ml-auto sm:w-56">
|
||||||
|
<MagnifyingGlass className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Başlık veya şehir ara…"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full border-input bg-background h-8 rounded-md border pl-8 pr-7 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearchQuery("")}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* List view */}
|
{/* List view */}
|
||||||
{viewMode === "list" && (
|
{viewMode === "list" && (
|
||||||
<div className="rounded-md border">
|
<>
|
||||||
<Table>
|
{/* Mobile cards — hidden on md+ */}
|
||||||
<TableHeader>
|
<div className="flex flex-col gap-3 md:hidden">
|
||||||
<TableRow>
|
{filteredProperties.length === 0 && (
|
||||||
<TableHead>Başlık</TableHead>
|
<p className="text-muted-foreground text-center py-10 text-sm">Henüz ilan yok.</p>
|
||||||
<TableHead>Tip</TableHead>
|
)}
|
||||||
<TableHead>Tür</TableHead>
|
{withImages.length > 0 && (
|
||||||
<TableHead>Şehir</TableHead>
|
<>
|
||||||
<TableHead className="text-right">Fiyat</TableHead>
|
{withoutImages.length > 0 && (
|
||||||
<TableHead>Durum</TableHead>
|
<ImageGroupHeader hasImages count={withImages.length} />
|
||||||
<TableHead />
|
)}
|
||||||
</TableRow>
|
{withImages.map((p) => <MobilePropertyCard key={p.$id} p={p} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />)}
|
||||||
</TableHeader>
|
</>
|
||||||
<TableBody>
|
)}
|
||||||
{properties.length === 0 && (
|
{withImages.length > 0 && withoutImages.length > 0 && (
|
||||||
|
<ImageGroupHeader hasImages={false} count={withoutImages.length} />
|
||||||
|
)}
|
||||||
|
{withoutImages.map((p) => <MobilePropertyCard key={p.$id} p={p} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop table — hidden on mobile */}
|
||||||
|
<div data-tour="properties-table" className="hidden md:block rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="text-muted-foreground text-center py-10">
|
<TableHead className="w-20" />
|
||||||
Henüz ilan yok.
|
<TableHead>Başlık</TableHead>
|
||||||
</TableCell>
|
<TableHead>Tip</TableHead>
|
||||||
</TableRow>
|
<TableHead>Tür</TableHead>
|
||||||
)}
|
<TableHead>Şehir</TableHead>
|
||||||
{properties.map((p) => (
|
<TableHead className="text-right">Fiyat</TableHead>
|
||||||
<TableRow
|
<TableHead>Durum</TableHead>
|
||||||
key={p.$id}
|
<TableHead />
|
||||||
ref={(el) => { if (el) rowRefs.current[p.$id] = el; }}
|
|
||||||
>
|
|
||||||
<TableCell className="font-medium max-w-[200px] truncate">{p.title}</TableCell>
|
|
||||||
<TableCell>{PROPERTY_TYPE_LABELS[p.propertyType] ?? p.propertyType}</TableCell>
|
|
||||||
<TableCell>{LISTING_TYPE_LABELS[p.listingType] ?? p.listingType}</TableCell>
|
|
||||||
<TableCell>{[p.city, p.district].filter(Boolean).join(", ")}</TableCell>
|
|
||||||
<TableCell className="text-right tabular-nums">
|
|
||||||
{p.price.toLocaleString("tr-TR")} {p.currency ?? "TRY"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<StatusBadge status={p.status} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="size-8">
|
|
||||||
<MoreHorizontal className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href={`/properties/${p.$id}`}>
|
|
||||||
<ExternalLink className="mr-2 size-4" />
|
|
||||||
Detay
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => openEdit(p)}>
|
|
||||||
<Pencil className="mr-2 size-4" />
|
|
||||||
Düzenle
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleDelete(p)}
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 size-4" />
|
|
||||||
Sil
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredProperties.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-muted-foreground text-center py-10">
|
||||||
|
Henüz ilan yok.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{withImages.length > 0 && withoutImages.length > 0 && (
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableCell colSpan={8} className="py-1.5 px-4">
|
||||||
|
<ImageGroupHeader hasImages count={withImages.length} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{withImages.map((p) => (
|
||||||
|
<PropertyTableRow key={p.$id} p={p} rowRefs={rowRefs} openEdit={openEdit} setDeleteTarget={setDeleteTarget} router={router} />
|
||||||
|
))}
|
||||||
|
{withImages.length > 0 && withoutImages.length > 0 && (
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableCell colSpan={8} className="py-1.5 px-4">
|
||||||
|
<ImageGroupHeader hasImages={false} count={withoutImages.length} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{withoutImages.map((p) => (
|
||||||
|
<PropertyTableRow key={p.$id} p={p} rowRefs={rowRefs} openEdit={openEdit} setDeleteTarget={setDeleteTarget} router={router} />
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Gallery view */}
|
||||||
|
{viewMode === "gallery" && (
|
||||||
|
<div>
|
||||||
|
{filteredProperties.length === 0 && (
|
||||||
|
<p className="text-muted-foreground text-center py-10 text-sm">Henüz ilan yok.</p>
|
||||||
|
)}
|
||||||
|
{withImages.length > 0 && (
|
||||||
|
<>
|
||||||
|
{withoutImages.length > 0 && <ImageGroupHeader hasImages count={withImages.length} />}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-3">
|
||||||
|
{withImages.map((p) => (
|
||||||
|
<GalleryCard key={p.$id} p={p} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{withImages.length > 0 && withoutImages.length > 0 && (
|
||||||
|
<div className="mt-6 mb-3">
|
||||||
|
<ImageGroupHeader hasImages={false} count={withoutImages.length} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{withoutImages.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-3">
|
||||||
|
{withoutImages.map((p) => (
|
||||||
|
<GalleryCard key={p.$id} p={p} openEdit={openEdit} setDeleteTarget={setDeleteTarget} />
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</div>
|
||||||
</Table>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Map view — split layout */}
|
{/* MapTrifold view — split layout */}
|
||||||
{viewMode === "map" && (
|
{viewMode === "map" && (
|
||||||
<div
|
<div
|
||||||
className="flex gap-4 min-h-0"
|
className="flex gap-4 min-h-0"
|
||||||
@@ -194,10 +310,10 @@ export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
|
|||||||
>
|
>
|
||||||
{/* Left: scrollable property cards */}
|
{/* Left: scrollable property cards */}
|
||||||
<div className="w-72 shrink-0 overflow-y-auto space-y-2 pr-1 h-full">
|
<div className="w-72 shrink-0 overflow-y-auto space-y-2 pr-1 h-full">
|
||||||
{properties.length === 0 && (
|
{filteredProperties.length === 0 && (
|
||||||
<p className="text-muted-foreground text-sm text-center py-10">Henüz ilan yok.</p>
|
<p className="text-muted-foreground text-sm text-center py-10">Henüz ilan yok.</p>
|
||||||
)}
|
)}
|
||||||
{properties.map((p) => {
|
{filteredProperties.map((p) => {
|
||||||
const coverImageId = parseImageIds(p.imageIds)[0];
|
const coverImageId = parseImageIds(p.imageIds)[0];
|
||||||
const hasCoords = p.mapLat != null && p.mapLng != null;
|
const hasCoords = p.mapLat != null && p.mapLng != null;
|
||||||
const isSelected = selectedId === p.$id;
|
const isSelected = selectedId === p.$id;
|
||||||
@@ -268,7 +384,7 @@ export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
|
|||||||
{/* Right: map */}
|
{/* Right: map */}
|
||||||
<div className="flex-1 min-w-0 h-full">
|
<div className="flex-1 min-w-0 h-full">
|
||||||
<PropertiesMapView
|
<PropertiesMapView
|
||||||
properties={properties}
|
properties={filteredProperties}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
onSelect={handleMapSelect}
|
onSelect={handleMapSelect}
|
||||||
/>
|
/>
|
||||||
@@ -280,15 +396,302 @@ export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
|
|||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
onOpenChange={setSheetOpen}
|
onOpenChange={setSheetOpen}
|
||||||
property={editing}
|
property={editing}
|
||||||
onSuccess={() => {}}
|
onSuccess={() => router.refresh()}
|
||||||
|
/>
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(v) => { if (!v) setDeleteTarget(null); }}
|
||||||
|
title={`"${deleteTarget?.title}" silinsin mi?`}
|
||||||
|
description="Bu ilan kalıcı olarak silinecek ve geri alınamaz."
|
||||||
|
onConfirm={doDelete}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Galeri kartı ── */
|
||||||
|
function GalleryCard({ p, openEdit, setDeleteTarget }: {
|
||||||
|
p: Property;
|
||||||
|
openEdit: (p: Property) => void;
|
||||||
|
setDeleteTarget: (p: Property) => void;
|
||||||
|
}) {
|
||||||
|
const imageIds = parseImageIds(p.imageIds);
|
||||||
|
const [idx, setIdx] = useState(0);
|
||||||
|
const [lightbox, setLightbox] = useState(false);
|
||||||
|
const safeIdx = Math.min(idx, imageIds.length - 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border bg-card overflow-hidden flex flex-col group">
|
||||||
|
{/* Görsel alanı */}
|
||||||
|
<div className="relative aspect-[4/3] bg-muted overflow-hidden">
|
||||||
|
{imageIds.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={getPropertyImagePreviewUrl(imageIds[safeIdx], 640, 480)}
|
||||||
|
alt={p.title}
|
||||||
|
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.02] cursor-zoom-in"
|
||||||
|
onClick={() => setLightbox(true)}
|
||||||
|
/>
|
||||||
|
{imageIds.length > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setIdx((i) => (i - 1 + imageIds.length) % imageIds.length); }}
|
||||||
|
className="absolute left-2 top-1/2 -translate-y-1/2 size-7 rounded-full bg-black/40 hover:bg-black/65 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<CaretLeft className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setIdx((i) => (i + 1) % imageIds.length); }}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 size-7 rounded-full bg-black/40 hover:bg-black/65 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<CaretRight className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<div className="absolute bottom-2 right-2 bg-black/50 text-white text-xs px-1.5 py-0.5 rounded-full tabular-nums opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{safeIdx + 1}/{imageIds.length}
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{imageIds.map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setIdx(i); }}
|
||||||
|
className={`size-1.5 rounded-full transition-colors ${i === safeIdx ? "bg-white" : "bg-white/40"}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link href={`/properties/${p.$id}`} className="flex h-full items-center justify-center flex-col gap-2">
|
||||||
|
<ImageSquare className="size-10 text-muted-foreground/30" />
|
||||||
|
<span className="text-xs text-muted-foreground/50">Görsel yok</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{/* Status badge üstte */}
|
||||||
|
<div className="absolute top-2 left-2">
|
||||||
|
<StatusBadge status={p.status} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lightbox && (
|
||||||
|
<ImageLightbox
|
||||||
|
imageIds={imageIds}
|
||||||
|
title={p.title}
|
||||||
|
initialIndex={safeIdx}
|
||||||
|
onClose={() => setLightbox(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bilgi + aksiyonlar */}
|
||||||
|
<div className="p-3 flex flex-col gap-1.5 flex-1">
|
||||||
|
<Link href={`/properties/${p.$id}`} className="font-semibold text-sm leading-snug line-clamp-2 hover:underline">
|
||||||
|
{p.title}
|
||||||
|
</Link>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{PROPERTY_TYPE_LABELS[p.propertyType] ?? p.propertyType}
|
||||||
|
{" · "}{LISTING_TYPE_LABELS[p.listingType] ?? p.listingType}
|
||||||
|
{p.city ? ` · ${p.city}` : ""}
|
||||||
|
{p.roomCount ? ` · ${p.roomCount}` : ""}
|
||||||
|
{p.netM2 ? ` · ${p.netM2}m²` : ""}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between mt-auto pt-1.5 border-t">
|
||||||
|
<p className="text-sm font-bold tabular-nums">
|
||||||
|
{p.price.toLocaleString("tr-TR")}
|
||||||
|
<span className="text-xs font-normal text-muted-foreground ml-1">{p.currency ?? "TRY"}</span>
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<Button variant="ghost" size="icon" className="size-7" onClick={() => openEdit(p)}>
|
||||||
|
<PencilSimple className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => setDeleteTarget(p)}
|
||||||
|
>
|
||||||
|
<Trash className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Görsel grup ayracı ── */
|
||||||
|
function ImageGroupHeader({ hasImages, count }: { hasImages: boolean; count: number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`flex items-center gap-1.5 text-xs font-semibold px-2 py-0.5 rounded-full ${
|
||||||
|
hasImages
|
||||||
|
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
|
||||||
|
: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400"
|
||||||
|
}`}>
|
||||||
|
<ImageSquare className="size-3" />
|
||||||
|
{hasImages ? "Görselli" : "Görselsiz"}
|
||||||
|
<span className="opacity-70 font-normal">({count})</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-px bg-border" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobil kart ── */
|
||||||
|
function MobilePropertyCard({ p, openEdit, setDeleteTarget }: {
|
||||||
|
p: Property;
|
||||||
|
openEdit: (p: Property) => void;
|
||||||
|
setDeleteTarget: (p: Property) => void;
|
||||||
|
}) {
|
||||||
|
const coverImageId = parseImageIds(p.imageIds)[0];
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-card overflow-hidden">
|
||||||
|
{coverImageId && (
|
||||||
|
<div className="h-36 overflow-hidden bg-muted">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={getPropertyImagePreviewUrl(coverImageId, 640, 288)}
|
||||||
|
alt={p.title}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="text-sm font-semibold leading-snug line-clamp-2 flex-1">{p.title}</p>
|
||||||
|
<StatusBadge status={p.status} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{PROPERTY_TYPE_LABELS[p.propertyType] ?? p.propertyType}
|
||||||
|
{" · "}{LISTING_TYPE_LABELS[p.listingType] ?? p.listingType}
|
||||||
|
{p.city ? ` · ${p.city}` : ""}
|
||||||
|
{p.district ? `, ${p.district}` : ""}
|
||||||
|
{p.roomCount ? ` · ${p.roomCount}` : ""}
|
||||||
|
{p.netM2 ? ` · ${p.netM2}m²` : ""}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-base font-bold tabular-nums">
|
||||||
|
{p.price.toLocaleString("tr-TR")}
|
||||||
|
<span className="text-xs font-normal text-muted-foreground ml-1">{p.currency ?? "TRY"}</span>
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="size-8" asChild>
|
||||||
|
<Link href={`/properties/${p.$id}`}>
|
||||||
|
<ArrowSquareOut className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="size-8" onClick={() => openEdit(p)}>
|
||||||
|
<PencilSimple className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => setDeleteTarget(p)}
|
||||||
|
>
|
||||||
|
<Trash className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tablo satırı ── */
|
||||||
|
function PropertyTableRow({ p, rowRefs, openEdit, setDeleteTarget, router }: {
|
||||||
|
p: Property;
|
||||||
|
rowRefs: React.MutableRefObject<Record<string, HTMLTableRowElement>>;
|
||||||
|
openEdit: (p: Property) => void;
|
||||||
|
setDeleteTarget: (p: Property) => void;
|
||||||
|
router: ReturnType<typeof useRouter>;
|
||||||
|
}) {
|
||||||
|
const imageIds = parseImageIds(p.imageIds);
|
||||||
|
const coverImageId = imageIds[0];
|
||||||
|
const [lightbox, setLightbox] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TableRow
|
||||||
|
ref={(el) => { if (el) rowRefs.current[p.$id] = el; }}
|
||||||
|
onClick={() => router.push(`/properties/${p.$id}`)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<TableCell className="py-1.5 pl-4 pr-2 w-20">
|
||||||
|
<div
|
||||||
|
onClick={(e) => { e.stopPropagation(); coverImageId && setLightbox(true); }}
|
||||||
|
className={`w-16 h-12 rounded overflow-hidden bg-muted flex items-center justify-center shrink-0 ${coverImageId ? "cursor-zoom-in" : ""}`}
|
||||||
|
>
|
||||||
|
{coverImageId ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={getPropertyImagePreviewUrl(coverImageId, 128, 96)}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ImageSquare className="size-5 text-muted-foreground/30" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium max-w-[200px] truncate">{p.title}</TableCell>
|
||||||
|
<TableCell>{PROPERTY_TYPE_LABELS[p.propertyType] ?? p.propertyType}</TableCell>
|
||||||
|
<TableCell>{LISTING_TYPE_LABELS[p.listingType] ?? p.listingType}</TableCell>
|
||||||
|
<TableCell>{[p.city, p.district].filter(Boolean).join(", ")}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{p.price.toLocaleString("tr-TR")} {p.currency ?? "TRY"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge status={p.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="size-8">
|
||||||
|
<DotsThree className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/properties/${p.$id}`}>
|
||||||
|
<ArrowSquareOut className="mr-2 size-4" />
|
||||||
|
Detay
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => openEdit(p)}>
|
||||||
|
<PencilSimple className="mr-2 size-4" />
|
||||||
|
Düzenle
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setDeleteTarget(p)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash className="mr-2 size-4" />
|
||||||
|
Sil
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{lightbox && (
|
||||||
|
<ImageLightbox
|
||||||
|
imageIds={imageIds}
|
||||||
|
title={p.title}
|
||||||
|
initialIndex={0}
|
||||||
|
onClose={() => setLightbox(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: string }) {
|
function StatusBadge({ status }: { status: string }) {
|
||||||
const map: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
|
const map: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
|
||||||
aktif: "default",
|
aktif: "default",
|
||||||
|
rezerve: "secondary",
|
||||||
pasif: "secondary",
|
pasif: "secondary",
|
||||||
satildi: "outline",
|
satildi: "outline",
|
||||||
kiralandit: "outline",
|
kiralandit: "outline",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user