init: kovakemlak-crm project scaffold
- Next.js 16 + Appwrite multi-tenant emlak CRM - Database: kovakemlak-db (properties, customers, customer_searches, property_matches, presentations, investors, activities, tenant_settings) - Same stack as isletmem-kovakcrm (shadcn/ui template base) - Modules: portföy, müşteri takibi, arama kriterleri, otomatik eşleştirme, sunum linki, yatırımcı portalı
@@ -0,0 +1,11 @@
|
||||
# Appwrite (self-hosted, v1.9.0)
|
||||
NEXT_PUBLIC_APPWRITE_ENDPOINT=https://db.kovaksoft.com/v1
|
||||
NEXT_PUBLIC_APPWRITE_PROJECT_ID=
|
||||
NEXT_PUBLIC_APPWRITE_DATABASE_ID=isletmem
|
||||
|
||||
# Server-only — Appwrite API key with sufficient scopes
|
||||
# (databases.read/write, tables.read/write, users.read/write, teams.read/write)
|
||||
APPWRITE_API_KEY=
|
||||
|
||||
# App
|
||||
APP_URL=http://localhost:3000
|
||||
@@ -0,0 +1,46 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# claude code (local-only)
|
||||
.claude/
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# KovakEmlak CRM
|
||||
|
||||
Multi-tenant emlak CRM. Emlak ofisleri portföy yönetimi, müşteri takibi, yatırımcı portalı ve portföy sunumu yapar. Her tenant kendi verilerini görür.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Next.js 16** + **React 19** (App Router, TypeScript)
|
||||
- **Tailwind CSS v4** + **shadcn/ui v3** (Radix primitives)
|
||||
- **Zustand** — client state
|
||||
- **TanStack Table**, **react-hook-form** + **Zod**, **Recharts**
|
||||
- **Appwrite** — DB, Auth, Storage, Teams (tenant izolasyonu)
|
||||
- **pnpm** — package manager
|
||||
- **Coolify** — Gitea webhook ile auto-deploy
|
||||
|
||||
## Multi-tenancy modeli
|
||||
|
||||
| Concept | Appwrite primitive |
|
||||
|---|---|
|
||||
| Tenant | Appwrite **Team** (1 team = 1 emlak ofisi) |
|
||||
| Tenant üyesi | Team membership (rol: `owner` / `admin` / `member`) |
|
||||
| Veri izolasyonu | Her doküman `tenantId` attribute + `Permission.read/update/delete(Role.team(tenantId))` |
|
||||
|
||||
## Modüller
|
||||
|
||||
| Modül | Collection | Notlar |
|
||||
|---|---|---|
|
||||
| Portföy | `properties` | İlan yönetimi (satılık/kiralık, tüm ilan tipleri) |
|
||||
| Müşteriler | `customers` | Alıcı / kiracı / yatırımcı adayları |
|
||||
| Arama Kriterleri | `customer_searches` | Müşteri bazlı saved search (oda, fiyat, lokasyon) |
|
||||
| Eşleşmeler | `property_matches` | Yeni ilan → kriter eşleşmesi, bildirim takibi |
|
||||
| Sunumlar | `presentations` | Seçilmiş ilanlar → paylaşılabilir sunum linki |
|
||||
| Yatırımcılar | `investors` | Portal erişimi olan yatırımcı profilleri |
|
||||
| Aktiviteler | `activities` | Görüşme, teklif, ziyaret, arama notları |
|
||||
| Ofis Ayarları | `tenant_settings` | Logo, para birimi, iletişim bilgileri |
|
||||
|
||||
## Appwrite Database
|
||||
|
||||
- **Database ID:** `kovakemlak-db`
|
||||
- **Project ID:** `69f27b51000a5bee46ce` (aynı proje, isletmem ile ortak)
|
||||
- **Endpoint:** `https://db.kovaksoft.com/v1`
|
||||
|
||||
## Özel Özellikler
|
||||
|
||||
### Otomatik Eşleştirme (Matching Engine)
|
||||
Yeni ilan eklendiğinde server action içinde `customer_searches` tablosu filtrelenerek kriterlere uyan müşteriler bulunur ve `property_matches`'e yazılır.
|
||||
|
||||
```
|
||||
yeni ilan kaydedildi
|
||||
→ matchPropertiesForSearches(propertyId, tenantId)
|
||||
→ customer_searches filtrele (listingType, propertyType, roomCount, fiyat aralığı, lokasyon)
|
||||
→ property_matches'e yaz (notified: false)
|
||||
→ danışmana bildirim
|
||||
```
|
||||
|
||||
### Portföy Sunumu
|
||||
Danışman birden fazla ilan seçer → `presentations` kaydı oluşur → `shareToken` ile public URL üretilir → müşteri `/sunum/[token]` rotasında giriş yapmadan görüntüler.
|
||||
|
||||
### Yatırımcı Portalı
|
||||
`investors.userId` Appwrite user ID'siyle eşleşir. Login olan yatırımcı `/portal` rotasında kendi kriterlerine uyan aktif ilanları görür.
|
||||
|
||||
## Rotalar
|
||||
|
||||
```
|
||||
(auth)/ login, register
|
||||
(dashboard)/ ana CRM (danışman)
|
||||
properties/ portföy listesi + detay + ekle
|
||||
customers/ müşteri listesi + arama kriterleri
|
||||
presentations/ sunum oluştur + paylaş
|
||||
investors/ yatırımcı profilleri
|
||||
activities/ aktivite takibi
|
||||
settings/ ofis ayarları
|
||||
(portal)/ yatırımcı portalı (ayrı layout)
|
||||
sunum/[token]/ public sunum sayfası (auth gerektirmez)
|
||||
```
|
||||
|
||||
## Komutlar
|
||||
|
||||
```bash
|
||||
pnpm dev # localhost:3001
|
||||
pnpm build
|
||||
pnpm lint
|
||||
pnpm typecheck # tsc --noEmit
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_APPWRITE_ENDPOINT=https://db.kovaksoft.com/v1
|
||||
NEXT_PUBLIC_APPWRITE_PROJECT_ID=69f27b51000a5bee46ce
|
||||
NEXT_PUBLIC_APPWRITE_DATABASE_ID=kovakemlak-db
|
||||
APPWRITE_API_KEY= # server-only
|
||||
APP_URL=https://emlak.kovakcrm.com
|
||||
```
|
||||
|
||||
## Geliştirme prensipleri
|
||||
|
||||
- **Template görselini bozma.** Sayfa layout, sidebar, theme korunur.
|
||||
- **Tenant filtresi şart.** Her query'de `tenantId` zorunlu.
|
||||
- **Server actions tercih edilir** — validation + audit için.
|
||||
- **Schema değişikliği = MCP çağrısı** (commit prefix: `db:`).
|
||||
- **Türkçe UI**, kod İngilizce.
|
||||
- **Matching engine idempotent** — aynı (propertyId, searchId) çifti için duplicate match yazma.
|
||||
|
||||
## Gitea Deploy
|
||||
|
||||
- **Repo:** `ssh://git@git.kovaksoft.com:2222/kovakmedya/kovakemlak-crm.git`
|
||||
- **Production domain:** `https://emlak.kovakcrm.com`
|
||||
- **Workflow:** `main` branch push → Coolify auto-deploy
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
experimental: {
|
||||
optimizePackageImports: ["lucide-react", "@radix-ui/react-icons"],
|
||||
},
|
||||
|
||||
serverActions: {
|
||||
bodySizeLimit: "3mb",
|
||||
},
|
||||
turbopack: {},
|
||||
|
||||
// TODO: re-enable once template files (chart.tsx, data-table-toolbar.tsx) are cleaned up.
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
|
||||
// Image optimization
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'ui.shadcn.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'images.unsplash.com',
|
||||
},
|
||||
],
|
||||
formats: ['image/webp', 'image/avif'],
|
||||
},
|
||||
|
||||
// Headers for better security and performance
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/(.*)',
|
||||
headers: [
|
||||
{
|
||||
key: 'X-Frame-Options',
|
||||
value: 'DENY',
|
||||
},
|
||||
{
|
||||
key: 'X-Content-Type-Options',
|
||||
value: 'nosniff',
|
||||
},
|
||||
{
|
||||
key: 'Referrer-Policy',
|
||||
value: 'origin-when-cross-origin',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// Redirects for better SEO
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/home',
|
||||
destination: '/dashboard',
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "kovakemlak-crm",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@polar-sh/sdk": "^0.47.1",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"appwrite": "^24.2.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-appwrite": "^23.1.0",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "19.2.3",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hook-form": "^7.69.0",
|
||||
"react-resizable-panels": "^3.0.4",
|
||||
"recharts": "3.6.0",
|
||||
"sonner": "^2.0.7",
|
||||
"svix": "^1.92.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.3.2",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.1.1",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
|
After Width: | Height: | Size: 842 KiB |
|
After Width: | Height: | Size: 444 KiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 475 KiB |
|
After Width: | Height: | Size: 768 KiB |
|
After Width: | Height: | Size: 551 B |
|
After Width: | Height: | Size: 655 B |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 112 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 938 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Image from "next/image"
|
||||
|
||||
export function ForbiddenError() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
|
||||
<Image
|
||||
src='https://ui.shadcn.com/placeholder.svg'
|
||||
alt='placeholder image'
|
||||
width={960}
|
||||
height={540}
|
||||
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
|
||||
/>
|
||||
<div className='text-center'>
|
||||
<h1 className='mb-4 text-3xl font-bold'>403</h1>
|
||||
<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>
|
||||
<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 variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
|
||||
Contact Us
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ForbiddenError } from "./components/forbidden-error"
|
||||
|
||||
export default function ForbiddenPage() {
|
||||
return <ForbiddenError />
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Image from "next/image"
|
||||
|
||||
export function InternalServerError() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
|
||||
<Image
|
||||
src='https://ui.shadcn.com/placeholder.svg'
|
||||
alt='placeholder image'
|
||||
width={960}
|
||||
height={540}
|
||||
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
|
||||
/>
|
||||
<div className='text-center'>
|
||||
<h1 className='mb-4 text-3xl font-bold'>500</h1>
|
||||
<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>
|
||||
<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 variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
|
||||
Contact Us
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { InternalServerError } from "./components/internal-server-error"
|
||||
|
||||
export default function InternalServerErrorPage() {
|
||||
return <InternalServerError />
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Image from "next/image"
|
||||
|
||||
export function NotFoundError() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
|
||||
<Image
|
||||
src='https://ui.shadcn.com/placeholder.svg'
|
||||
alt='placeholder image'
|
||||
width={960}
|
||||
height={540}
|
||||
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
|
||||
/>
|
||||
<div className='text-center'>
|
||||
<h1 className='mb-4 text-3xl font-bold'>404</h1>
|
||||
<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>
|
||||
<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 variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
|
||||
Contact Us
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NotFoundError } from "./components/not-found-error"
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return <NotFoundError />
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Image from "next/image"
|
||||
|
||||
export function UnauthorizedError() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
|
||||
<Image
|
||||
src='https://ui.shadcn.com/placeholder.svg'
|
||||
alt='placeholder image'
|
||||
width={960}
|
||||
height={540}
|
||||
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
|
||||
/>
|
||||
<div className='text-center'>
|
||||
<h1 className='mb-4 text-3xl font-bold'>401</h1>
|
||||
<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>
|
||||
<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 variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
|
||||
Contact Us
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { UnauthorizedError } from "./components/unauthorized-error"
|
||||
|
||||
export default function UnauthorizedPage() {
|
||||
return <UnauthorizedError />
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Image from "next/image"
|
||||
|
||||
export function UnderMaintenanceError() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className='mx-auto flex min-h-dvh flex-col items-center justify-center gap-8 p-8 md:gap-12 md:p-16'>
|
||||
<Image
|
||||
src='https://ui.shadcn.com/placeholder.svg'
|
||||
alt='placeholder image'
|
||||
width={960}
|
||||
height={540}
|
||||
className='aspect-video w-240 rounded-xl object-cover dark:brightness-[0.95] dark:invert'
|
||||
/>
|
||||
<div className='text-center'>
|
||||
<h1 className='mb-4 text-3xl font-bold'>503</h1>
|
||||
<h2 className="mb-3 text-2xl font-semibold">Under Maintenance</h2>
|
||||
<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'>
|
||||
<Button className='cursor-pointer' onClick={() => router.push('/dashboard')}>Go Back Home</Button>
|
||||
<Button variant='outline' className='flex cursor-pointer items-center gap-1' onClick={() => router.push('#')}>
|
||||
Contact Us
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { UnderMaintenanceError } from "./components/under-maintenance-error"
|
||||
|
||||
export default function UnderMaintenancePage() {
|
||||
return <UnderMaintenanceError />
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
export function ForgotPasswordForm2({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"form">) {
|
||||
return (
|
||||
<form className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<h1 className="text-2xl font-bold">Forgot your password?</h1>
|
||||
<p className="text-muted-foreground text-sm text-balance">
|
||||
Enter your email address and we'll send you a link to reset your password
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" type="email" placeholder="m@example.com" required />
|
||||
</div>
|
||||
<Button type="submit" className="w-full cursor-pointer">
|
||||
Send Reset Link
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center text-sm">
|
||||
Remember your password?{" "}
|
||||
<a href="/auth/sign-in-2" className="underline underline-offset-4">
|
||||
Back to sign in
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ForgotPasswordForm2 } from "./components/forgot-password-form-2"
|
||||
import { Logo } from "@/components/logo"
|
||||
import Link from "next/link"
|
||||
import Image from "next/image"
|
||||
|
||||
export default function ForgotPassword2Page() {
|
||||
return (
|
||||
<div className="grid min-h-svh lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-4 p-6 md:p-10">
|
||||
<div className="flex justify-center gap-2 md:justify-start">
|
||||
<Link href="/" className="flex items-center gap-2 font-medium">
|
||||
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
|
||||
<Logo size={24} />
|
||||
</div>
|
||||
ShadcnStore
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="w-full max-w-md">
|
||||
<ForgotPasswordForm2 />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-muted relative hidden lg:block">
|
||||
<Image
|
||||
src="https://ui.shadcn.com/placeholder.svg"
|
||||
alt="Image"
|
||||
fill
|
||||
className="object-cover dark:brightness-[0.95] dark:invert"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Logo } from "@/components/logo"
|
||||
import Link from "next/link"
|
||||
import Image from "next/image"
|
||||
|
||||
export function ForgotPasswordForm3({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card className="overflow-hidden p-0">
|
||||
<CardContent className="grid p-0 md:grid-cols-2">
|
||||
<form className="p-6 md:p-8">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex justify-center mb-2">
|
||||
<Link href="/" className="flex items-center gap-2 font-medium">
|
||||
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
|
||||
<Logo size={24} />
|
||||
</div>
|
||||
<span className="text-xl">ShadcnStore</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<h1 className="text-2xl font-bold">Forgot your password?</h1>
|
||||
<p className="text-muted-foreground text-balance">
|
||||
Enter your email to reset your ShadcnStore account password
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full cursor-pointer">
|
||||
Send Reset Link
|
||||
</Button>
|
||||
<div className="text-center text-sm">
|
||||
Remember your password?{" "}
|
||||
<a href="/auth/sign-in-3" className="underline underline-offset-4">
|
||||
Back to sign in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div className="bg-muted relative hidden md:block">
|
||||
<Image
|
||||
src="https://ui.shadcn.com/placeholder.svg"
|
||||
alt="Image"
|
||||
fill
|
||||
className="object-cover dark:brightness-[0.95] dark:invert"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
|
||||
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
|
||||
and <a href="#">Privacy Policy</a>.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ForgotPasswordForm3 } from "./components/forgot-password-form-3"
|
||||
|
||||
export default function ForgotPassword3Page() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<ForgotPasswordForm3 className="w-full max-w-sm md:max-w-4xl" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useActionState } from "react";
|
||||
import { ArrowLeft, Loader2, MailCheck } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { forgotPasswordAction } from "@/lib/appwrite/auth-actions";
|
||||
import { initialAuthState } from "@/lib/appwrite/auth-types";
|
||||
|
||||
export function ForgotPasswordForm1({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const [state, formAction, isPending] = useActionState(forgotPasswordAction, initialAuthState);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">Şifremi unuttum</CardTitle>
|
||||
<CardDescription>
|
||||
Email adresinizi girin, sıfırlama bağlantısı gönderelim.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{state.ok ? (
|
||||
<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">
|
||||
<MailCheck className="size-6" />
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
Bağlantı emailinize gönderildi. Gelen kutusunu kontrol edin.
|
||||
</p>
|
||||
<Link
|
||||
href="/sign-in"
|
||||
className="text-muted-foreground hover:text-foreground mt-2 flex items-center gap-1 text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
Giriş sayfasına dön
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form action={formAction} className="flex flex-col gap-4">
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="ornek@firma.com"
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state.error && (
|
||||
<p className="text-destructive text-sm text-center" role="alert">
|
||||
{state.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Gönderiliyor...
|
||||
</>
|
||||
) : (
|
||||
"Sıfırlama bağlantısı gönder"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Link
|
||||
href="/sign-in"
|
||||
className="text-muted-foreground hover:text-foreground flex items-center justify-center gap-1 text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
Giriş sayfasına dön
|
||||
</Link>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { ForgotPasswordForm1 } from "./components/forgot-password-form-1";
|
||||
import { Logo } from "@/components/logo";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||
<Link href="/" className="flex items-center gap-2 self-center font-medium">
|
||||
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
|
||||
<Logo size={24} />
|
||||
</div>
|
||||
<span className="text-lg font-semibold">İşletmem</span>
|
||||
</Link>
|
||||
<ForgotPasswordForm1 />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "İşletmem — Giriş",
|
||||
description: "İşletmem KovakCRM hesabınıza giriş yapın veya yeni hesap oluşturun.",
|
||||
};
|
||||
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="min-h-screen bg-background">{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
export function LoginForm2({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"form">) {
|
||||
return (
|
||||
<form className={cn("flex flex-col gap-6", className)} {...props} action="/dashboard">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<h1 className="text-2xl font-bold">Login to your account</h1>
|
||||
<p className="text-muted-foreground text-sm text-balance">
|
||||
Enter your email below to login to your account
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" type="email" placeholder="test@example.com" defaultValue="test@example.com" required />
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<a
|
||||
href="/auth/forgot-password-2"
|
||||
className="ml-auto text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
Forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
<Input id="password" type="password" defaultValue="password" required />
|
||||
</div>
|
||||
<Button type="submit" className="w-full cursor-pointer">
|
||||
Login
|
||||
</Button>
|
||||
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
|
||||
<span className="bg-background text-muted-foreground relative z-10 px-2">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full cursor-pointer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Login with GitHub
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<a href="/auth/sign-up-2" className="underline underline-offset-4">
|
||||
Sign up
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { LoginForm2 } from "./components/login-form-2"
|
||||
import { Logo } from "@/components/logo"
|
||||
import Link from "next/link"
|
||||
import Image from "next/image"
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="grid min-h-svh lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-4 p-6 md:p-10">
|
||||
<div className="flex justify-center gap-2 md:justify-start">
|
||||
<Link href="/" className="flex items-center gap-2 font-medium">
|
||||
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
|
||||
<Logo size={24} />
|
||||
</div>
|
||||
ShadcnStore
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="w-full max-w-md">
|
||||
<LoginForm2 />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-muted relative hidden lg:block">
|
||||
<Image
|
||||
src="https://ui.shadcn.com/placeholder.svg"
|
||||
alt="Image"
|
||||
fill
|
||||
className="object-cover dark:brightness-[0.95] dark:invert"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Logo } from "@/components/logo"
|
||||
import Link from "next/link"
|
||||
import Image from "next/image"
|
||||
|
||||
export function LoginForm3({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card className="overflow-hidden p-0">
|
||||
<CardContent className="grid p-0 md:grid-cols-2">
|
||||
<form className="p-6 md:p-8" action="/dashboard">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex justify-center mb-2">
|
||||
<Link href="/" className="flex items-center gap-2 font-medium">
|
||||
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
|
||||
<Logo size={24} />
|
||||
</div>
|
||||
<span className="text-xl">ShadcnStore</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<h1 className="text-2xl font-bold">Welcome back</h1>
|
||||
<p className="text-muted-foreground text-balance">
|
||||
Login to your ShadcnStore account
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="test@example.com"
|
||||
defaultValue="test@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<a
|
||||
href="/auth/forgot-password-3"
|
||||
className="ml-auto text-sm underline-offset-2 hover:underline"
|
||||
>
|
||||
Forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
<Input id="password" type="password" defaultValue="password" required />
|
||||
</div>
|
||||
<Button type="submit" className="w-full cursor-pointer">
|
||||
Login
|
||||
</Button>
|
||||
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
|
||||
<span className="bg-card text-muted-foreground relative z-10 px-2">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Button variant="outline" type="button" className="w-full cursor-pointer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Login with Apple</span>
|
||||
</Button>
|
||||
<Button variant="outline" type="button" className="w-full cursor-pointer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Login with Google</span>
|
||||
</Button>
|
||||
<Button variant="outline" type="button" className="w-full cursor-pointer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Login with Meta</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<a href="/auth/sign-up-3" className="underline underline-offset-4">
|
||||
Sign up
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div className="bg-muted relative hidden md:block">
|
||||
<Image
|
||||
src="https://ui.shadcn.com/placeholder.svg"
|
||||
alt="Image"
|
||||
fill
|
||||
className="object-cover dark:brightness-[0.95] dark:invert"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
|
||||
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
|
||||
and <a href="#">Privacy Policy</a>.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { LoginForm3 } from "./components/login-form-3"
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm md:max-w-4xl">
|
||||
<LoginForm3 />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useActionState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Logo } from "@/components/logo";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { signInAction } from "@/lib/appwrite/auth-actions";
|
||||
import { initialAuthState } from "@/lib/appwrite/auth-types";
|
||||
|
||||
export function LoginForm1({
|
||||
className,
|
||||
inviteCode,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { inviteCode?: string }) {
|
||||
const [state, formAction, isPending] = useActionState(signInAction, initialAuthState);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card className="overflow-hidden p-0">
|
||||
<CardContent className="grid p-0 md:grid-cols-2">
|
||||
<form action={formAction} className="p-6 md:p-10">
|
||||
{inviteCode && <input type="hidden" name="inviteCode" value={inviteCode} />}
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex justify-center">
|
||||
<Link href="/" className="flex items-center gap-2 font-medium">
|
||||
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
|
||||
<Logo size={22} />
|
||||
</div>
|
||||
<span className="text-xl font-semibold">İşletmem</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{inviteCode && (
|
||||
<p className="text-muted-foreground rounded-md border bg-muted/50 px-3 py-2 text-center text-xs">
|
||||
Davete katılmak için giriş yapın.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Tekrar hoş geldiniz</h1>
|
||||
<p className="text-muted-foreground text-sm text-balance mt-1">
|
||||
Hesabınıza giriş yaparak işletmenizi yönetmeye devam edin
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="ornek@firma.com"
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">Şifre</Label>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="ml-auto text-xs text-muted-foreground hover:text-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
Şifremi unuttum
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state.error && (
|
||||
<p className="text-destructive text-sm text-center" role="alert">
|
||||
{state.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Giriş yapılıyor...
|
||||
</>
|
||||
) : (
|
||||
"Giriş yap"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
Hesabınız yok mu?{" "}
|
||||
<Link
|
||||
href="/sign-up"
|
||||
className="text-foreground font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
Hesap oluştur
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<BrandPanel />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-muted-foreground text-center text-xs text-balance">
|
||||
Giriş yaparak{" "}
|
||||
<Link href="#" className="underline-offset-4 hover:underline">
|
||||
Kullanım Şartları
|
||||
</Link>{" "}
|
||||
ve{" "}
|
||||
<Link href="#" className="underline-offset-4 hover:underline">
|
||||
Gizlilik Politikası
|
||||
</Link>
|
||||
'nı kabul etmiş olursunuz.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BrandPanel() {
|
||||
return (
|
||||
<div className="bg-primary text-primary-foreground relative hidden md:flex md:flex-col md:justify-between overflow-hidden p-10">
|
||||
<div
|
||||
className="absolute inset-0 opacity-30"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 20% 20%, rgba(255,255,255,0.4) 0%, transparent 40%), radial-gradient(circle at 80% 80%, rgba(255,255,255,0.25) 0%, transparent 45%)",
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="absolute -top-24 -right-24 size-72 rounded-full bg-white/10 blur-3xl"
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-32 -left-20 size-80 rounded-full bg-black/10 blur-3xl"
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
<div className="relative z-10 flex items-center gap-2">
|
||||
<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} />
|
||||
</div>
|
||||
<span className="text-lg font-medium">İşletmem</span>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex flex-col gap-3">
|
||||
<h2 className="text-3xl font-semibold leading-tight">
|
||||
Müşteriden faturaya, tek panelden işletmenizi yönetin.
|
||||
</h2>
|
||||
<p className="text-primary-foreground/80 text-sm">
|
||||
Müşteriler, hizmetler, takvim, görevler ve finans — hepsi tek yerde, multi-tenant ve ekibinize özel.
|
||||
</p>
|
||||
<div className="text-primary-foreground/70 mt-4 text-xs">KovakSoft tarafından</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { LoginForm1 } from "./components/login-form-1";
|
||||
import { getCurrentUser } from "@/lib/appwrite/server";
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ invite?: string }>;
|
||||
}) {
|
||||
const { invite } = await searchParams;
|
||||
const user = await getCurrentUser();
|
||||
if (user) redirect(invite ? `/d/${invite}` : "/dashboard");
|
||||
|
||||
return (
|
||||
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm md:max-w-4xl">
|
||||
<LoginForm1 inviteCode={invite} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
|
||||
export function SignupForm2({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"form">) {
|
||||
return (
|
||||
<form className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<h1 className="text-2xl font-bold">Create your account</h1>
|
||||
<p className="text-muted-foreground text-sm text-balance">
|
||||
Enter your information to create a new account
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="firstName">First Name</Label>
|
||||
<Input id="firstName" placeholder="John" required />
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="lastName">Last Name</Label>
|
||||
<Input id="lastName" placeholder="Doe" required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" type="email" placeholder="m@example.com" required />
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input id="password" type="password" required />
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<Input id="confirmPassword" type="password" required />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="terms" required />
|
||||
<Label htmlFor="terms" className="text-sm">
|
||||
I agree to the{" "}
|
||||
<a href="#" className="underline underline-offset-4 hover:text-primary">
|
||||
Terms of Service
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a href="#" className="underline underline-offset-4 hover:text-primary">
|
||||
Privacy Policy
|
||||
</a>
|
||||
</Label>
|
||||
</div>
|
||||
<Button type="submit" className="w-full cursor-pointer">
|
||||
Create Account
|
||||
</Button>
|
||||
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
|
||||
<span className="bg-background text-muted-foreground relative z-10 px-2">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full cursor-pointer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Sign up with GitHub
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center text-sm">
|
||||
Already have an account?{" "}
|
||||
<a href="/auth/sign-in-2" className="underline underline-offset-4">
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { SignupForm2 } from "./components/signup-form-2"
|
||||
import { Logo } from "@/components/logo"
|
||||
import Link from "next/link"
|
||||
import Image from "next/image"
|
||||
|
||||
export default function SignUp2Page() {
|
||||
return (
|
||||
<div className="grid min-h-svh lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-4 p-6 md:p-10">
|
||||
<div className="flex justify-center gap-2 md:justify-start">
|
||||
<Link href="/" className="flex items-center gap-2 font-medium">
|
||||
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
|
||||
<Logo size={24} />
|
||||
</div>
|
||||
ShadcnStore
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="w-full max-w-md">
|
||||
<SignupForm2 />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-muted relative hidden lg:block">
|
||||
<Image
|
||||
src="https://ui.shadcn.com/placeholder.svg"
|
||||
alt="Image"
|
||||
fill
|
||||
className="object-cover dark:brightness-[0.95] dark:invert"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Logo } from "@/components/logo"
|
||||
import Link from "next/link"
|
||||
import Image from "next/image"
|
||||
|
||||
export function SignupForm3({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card className="overflow-hidden p-0">
|
||||
<CardContent className="grid p-0 md:grid-cols-2">
|
||||
<form className="p-6 md:p-8">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex justify-center mb-2">
|
||||
<Link href="/" className="flex items-center gap-2 font-medium">
|
||||
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md">
|
||||
<Logo size={24} />
|
||||
</div>
|
||||
<span className="text-xl">ShadcnStore</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<h1 className="text-2xl font-bold">Create your account</h1>
|
||||
<p className="text-muted-foreground text-balance">
|
||||
Enter your information to create a new account
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="firstName">First Name</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
placeholder="John"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="lastName">Last Name</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
placeholder="Doe"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input id="password" type="password" required />
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<Input id="confirmPassword" type="password" required />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="terms" required />
|
||||
<Label htmlFor="terms" className="text-sm">
|
||||
I agree to the{" "}
|
||||
<a href="#" className="underline underline-offset-4 hover:text-primary">
|
||||
Terms of Service
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a href="#" className="underline underline-offset-4 hover:text-primary">
|
||||
Privacy Policy
|
||||
</a>
|
||||
</Label>
|
||||
</div>
|
||||
<Button type="submit" className="w-full cursor-pointer">
|
||||
Create Account
|
||||
</Button>
|
||||
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
|
||||
<span className="bg-card text-muted-foreground relative z-10 px-2">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Button variant="outline" type="button" className="w-full cursor-pointer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Sign up with Apple</span>
|
||||
</Button>
|
||||
<Button variant="outline" type="button" className="w-full cursor-pointer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Sign up with Google</span>
|
||||
</Button>
|
||||
<Button variant="outline" type="button" className="w-full cursor-pointer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Sign up with Meta</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center text-sm">
|
||||
Already have an account?{" "}
|
||||
<a href="/auth/sign-in-3" className="underline underline-offset-4">
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div className="bg-muted relative hidden md:block">
|
||||
<Image
|
||||
src="https://ui.shadcn.com/placeholder.svg"
|
||||
alt="Image"
|
||||
fill
|
||||
className="object-cover dark:brightness-[0.95] dark:invert"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
|
||||
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
|
||||
and <a href="#">Privacy Policy</a>.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SignupForm3 } from "./components/signup-form-3"
|
||||
|
||||
export default function SignUp3Page() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<SignupForm3 className="w-full max-w-5xl" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useActionState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Logo } from "@/components/logo";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { signUpAction } from "@/lib/appwrite/auth-actions";
|
||||
import { initialAuthState } from "@/lib/appwrite/auth-types";
|
||||
|
||||
export function SignupForm1({
|
||||
className,
|
||||
inviteCode,
|
||||
prefilledEmail,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { inviteCode?: string; prefilledEmail?: string }) {
|
||||
const [state, formAction, isPending] = useActionState(signUpAction, initialAuthState);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card className="overflow-hidden p-0">
|
||||
<CardContent className="grid p-0 md:grid-cols-2">
|
||||
<BrandPanel />
|
||||
|
||||
<form action={formAction} className="p-6 md:p-10">
|
||||
{inviteCode && <input type="hidden" name="inviteCode" value={inviteCode} />}
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex justify-center">
|
||||
<Link href="/" className="flex items-center gap-2 font-medium">
|
||||
<div className="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-md">
|
||||
<Logo size={22} />
|
||||
</div>
|
||||
<span className="text-xl font-semibold">İşletmem</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{inviteCode ? "Davete katıl" : "Hesap oluşturun"}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm text-balance mt-1">
|
||||
{inviteCode
|
||||
? "Hesap oluşturduktan sonra çalışma alanına otomatik katılacaksınız"
|
||||
: "Birkaç saniye içinde hesabınız hazır"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="name">Adınız Soyadınız</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="Ahmet Yılmaz"
|
||||
autoComplete="name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="ornek@firma.com"
|
||||
autoComplete="email"
|
||||
defaultValue={prefilledEmail}
|
||||
readOnly={Boolean(prefilledEmail)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="password">Şifre</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">En az 8 karakter</p>
|
||||
</div>
|
||||
|
||||
{state.error && (
|
||||
<p className="text-destructive text-sm text-center" role="alert">
|
||||
{state.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Hesap oluşturuluyor...
|
||||
</>
|
||||
) : (
|
||||
"Hesap oluştur"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
Zaten hesabınız var mı?{" "}
|
||||
<Link
|
||||
href="/sign-in"
|
||||
className="text-foreground font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
Giriş yap
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-muted-foreground text-center text-xs text-balance">
|
||||
Hesap oluşturarak{" "}
|
||||
<Link href="#" className="underline-offset-4 hover:underline">
|
||||
Kullanım Şartları
|
||||
</Link>{" "}
|
||||
ve{" "}
|
||||
<Link href="#" className="underline-offset-4 hover:underline">
|
||||
Gizlilik Politikası
|
||||
</Link>
|
||||
'nı kabul etmiş olursunuz.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BrandPanel() {
|
||||
return (
|
||||
<div className="bg-primary text-primary-foreground relative hidden md:flex md:flex-col md:justify-between overflow-hidden p-10">
|
||||
<div
|
||||
className="absolute inset-0 opacity-30"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 20% 20%, rgba(255,255,255,0.4) 0%, transparent 40%), radial-gradient(circle at 80% 80%, rgba(255,255,255,0.25) 0%, transparent 45%)",
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="absolute -top-24 -left-24 size-72 rounded-full bg-white/10 blur-3xl"
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-32 -right-20 size-80 rounded-full bg-black/10 blur-3xl"
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
<div className="relative z-10 flex items-center gap-2">
|
||||
<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} />
|
||||
</div>
|
||||
<span className="text-lg font-medium">İşletmem</span>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex flex-col gap-3">
|
||||
<h2 className="text-3xl font-semibold leading-tight">
|
||||
İşletmenizi büyütecek tek araç.
|
||||
</h2>
|
||||
<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.
|
||||
</p>
|
||||
<ul className="text-primary-foreground/85 mt-2 space-y-1 text-sm">
|
||||
<li>• Müşteri & hizmet yönetimi</li>
|
||||
<li>• Görev ve takvim</li>
|
||||
<li>• Finans ve fatura</li>
|
||||
</ul>
|
||||
<div className="text-primary-foreground/70 mt-4 text-xs">KovakSoft tarafından</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { SignupForm1 } from "./components/signup-form-1";
|
||||
import { getCurrentUser } from "@/lib/appwrite/server";
|
||||
|
||||
export default async function SignUpPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ invite?: string; email?: string }>;
|
||||
}) {
|
||||
const { invite, email } = await searchParams;
|
||||
const user = await getCurrentUser();
|
||||
if (user) redirect(invite ? `/d/${invite}` : "/dashboard");
|
||||
|
||||
return (
|
||||
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm md:max-w-4xl">
|
||||
<SignupForm1 inviteCode={invite} prefilledEmail={email} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { ChevronLeft, ChevronRight, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { deleteCalendarEventAction } from "@/lib/appwrite/calendar-actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { EventFormSheet } from "./event-form-sheet";
|
||||
import { COLOR_BG, type Customer, type EventRow } from "./types";
|
||||
|
||||
type Props = {
|
||||
events: EventRow[];
|
||||
customers: Customer[];
|
||||
};
|
||||
|
||||
const WEEKDAYS = ["Pzt", "Sal", "Çar", "Per", "Cum", "Cmt", "Paz"];
|
||||
const MONTH_NAMES = [
|
||||
"Ocak", "Şubat", "Mart", "Nisan", "Mayıs", "Haziran",
|
||||
"Temmuz", "Ağustos", "Eylül", "Ekim", "Kasım", "Aralık",
|
||||
];
|
||||
|
||||
function startOfMonthGrid(year: number, month: number): Date {
|
||||
// Monday-first grid; first cell is the Monday on/before the 1st
|
||||
const first = new Date(year, month, 1);
|
||||
const dayIdx = (first.getDay() + 6) % 7; // 0 = Mon
|
||||
return new Date(year, month, 1 - dayIdx);
|
||||
}
|
||||
|
||||
function ymd(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
export function CalendarClient({ events, customers }: Props) {
|
||||
const today = new Date();
|
||||
const [cursor, setCursor] = useState(new Date(today.getFullYear(), today.getMonth(), 1));
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<EventRow | null>(null);
|
||||
const [defaultDate, setDefaultDate] = useState<string | undefined>();
|
||||
const [deleting, setDeleting] = useState<EventRow | null>(null);
|
||||
const [busy, startTransition] = useTransition();
|
||||
|
||||
const eventsByDay = useMemo(() => {
|
||||
const map = new Map<string, EventRow[]>();
|
||||
for (const e of events) {
|
||||
const start = new Date(e.start);
|
||||
const end = new Date(e.end);
|
||||
const cur = new Date(start.getFullYear(), start.getMonth(), start.getDate());
|
||||
const last = new Date(end.getFullYear(), end.getMonth(), end.getDate());
|
||||
while (cur.getTime() <= last.getTime()) {
|
||||
const key = ymd(cur);
|
||||
const arr = map.get(key) ?? [];
|
||||
arr.push(e);
|
||||
map.set(key, arr);
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
const grid = useMemo(() => {
|
||||
const start = startOfMonthGrid(cursor.getFullYear(), cursor.getMonth());
|
||||
const days: Date[] = [];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const d = new Date(start);
|
||||
d.setDate(start.getDate() + i);
|
||||
days.push(d);
|
||||
}
|
||||
return days;
|
||||
}, [cursor]);
|
||||
|
||||
const handlePrev = () => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() - 1, 1));
|
||||
const handleNext = () => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1));
|
||||
const handleToday = () => setCursor(new Date(today.getFullYear(), today.getMonth(), 1));
|
||||
|
||||
const handleAddOnDay = (date: Date) => {
|
||||
setEditing(null);
|
||||
setDefaultDate(ymd(date));
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handleAddNew = () => {
|
||||
setEditing(null);
|
||||
setDefaultDate(ymd(today));
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (event: EventRow) => {
|
||||
setEditing(event);
|
||||
setDefaultDate(undefined);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleting) return;
|
||||
startTransition(async () => {
|
||||
const fd = new FormData();
|
||||
fd.set("id", deleting.id);
|
||||
const result = await deleteCalendarEventAction(fd);
|
||||
if (result.ok) {
|
||||
toast.success("Etkinlik silindi.");
|
||||
setDeleting(null);
|
||||
} else {
|
||||
toast.error(result.error ?? "Silme başarısız.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const todayKey = ymd(today);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-4 flex flex-col items-center justify-between gap-3 md:flex-row">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon" className="size-8" onClick={handlePrev}>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{MONTH_NAMES[cursor.getMonth()]} {cursor.getFullYear()}
|
||||
</h2>
|
||||
<Button variant="outline" size="icon" className="size-8" onClick={handleNext}>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleToday}>
|
||||
Bugün
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={handleAddNew}>
|
||||
<Plus className="size-4" />
|
||||
Yeni etkinlik
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-px overflow-hidden rounded-md border bg-border">
|
||||
{WEEKDAYS.map((wd) => (
|
||||
<div
|
||||
key={wd}
|
||||
className="bg-muted/40 text-muted-foreground py-2 text-center text-xs font-medium"
|
||||
>
|
||||
{wd}
|
||||
</div>
|
||||
))}
|
||||
{grid.map((d) => {
|
||||
const inMonth = d.getMonth() === cursor.getMonth();
|
||||
const key = ymd(d);
|
||||
const isToday = key === todayKey;
|
||||
const dayEvents = eventsByDay.get(key) ?? [];
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
"bg-card group relative flex min-h-[110px] flex-col gap-1 p-1.5",
|
||||
!inMonth && "bg-muted/30",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex size-6 items-center justify-center rounded-full text-xs",
|
||||
isToday && "bg-primary text-primary-foreground font-medium",
|
||||
!inMonth && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{d.getDate()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddOnDay(d)}
|
||||
className="text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100"
|
||||
aria-label="Bu güne etkinlik ekle"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{dayEvents.slice(0, 3).map((e) => (
|
||||
<button
|
||||
key={e.id}
|
||||
type="button"
|
||||
onClick={() => handleEdit(e)}
|
||||
className={cn(
|
||||
"truncate rounded border px-1.5 py-0.5 text-left text-xs",
|
||||
COLOR_BG[e.color] ?? COLOR_BG[""],
|
||||
)}
|
||||
title={e.title}
|
||||
>
|
||||
{!e.allDay && (
|
||||
<span className="opacity-70">
|
||||
{new Date(e.start).toLocaleTimeString("tr-TR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}{" "}
|
||||
</span>
|
||||
)}
|
||||
{e.title}
|
||||
</button>
|
||||
))}
|
||||
{dayEvents.length > 3 && (
|
||||
<span className="text-muted-foreground px-1 text-xs">
|
||||
+{dayEvents.length - 3} daha
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<EventFormSheet
|
||||
open={formOpen}
|
||||
onOpenChange={(v) => {
|
||||
setFormOpen(v);
|
||||
if (!v) setEditing(null);
|
||||
}}
|
||||
event={editing}
|
||||
defaultDate={defaultDate}
|
||||
customers={customers}
|
||||
onRequestDelete={(e) => {
|
||||
setFormOpen(false);
|
||||
setDeleting(e);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(deleting)} onOpenChange={(v) => !v && setDeleting(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Etkinliği sil</DialogTitle>
|
||||
<DialogDescription>
|
||||
<strong>{deleting?.title}</strong> kalıcı olarak silinecek.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleting(null)} disabled={busy}>
|
||||
Vazgeç
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={busy}>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
|
||||
Sil
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import { Loader2, Save, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
createCalendarEventAction,
|
||||
updateCalendarEventAction,
|
||||
} from "@/lib/appwrite/calendar-actions";
|
||||
import { initialCalendarState } from "@/lib/appwrite/calendar-types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { COLOR_PRESETS, type Customer, type EventRow } from "./types";
|
||||
|
||||
const NONE = "__none__";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
event?: EventRow | null;
|
||||
defaultDate?: string; // YYYY-MM-DD for new events
|
||||
customers: Customer[];
|
||||
onRequestDelete?: (event: EventRow) => void;
|
||||
};
|
||||
|
||||
function isoToInput(iso: string, allDay: boolean): string {
|
||||
if (!iso) return "";
|
||||
if (allDay) return iso.slice(0, 10);
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function EventFormSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
event,
|
||||
defaultDate,
|
||||
customers,
|
||||
onRequestDelete,
|
||||
}: Props) {
|
||||
const isEdit = Boolean(event);
|
||||
const action = isEdit ? updateCalendarEventAction : createCalendarEventAction;
|
||||
const [state, formAction, isPending] = useActionState(action, initialCalendarState);
|
||||
const [allDay, setAllDay] = useState<boolean>(event?.allDay ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
setAllDay(event?.allDay ?? false);
|
||||
}, [event]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success(isEdit ? "Etkinlik güncellendi." : "Etkinlik eklendi.");
|
||||
onOpenChange(false);
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state]);
|
||||
|
||||
const startDefault =
|
||||
event?.start
|
||||
? isoToInput(event.start, allDay)
|
||||
: defaultDate
|
||||
? allDay
|
||||
? defaultDate
|
||||
: `${defaultDate}T09:00`
|
||||
: "";
|
||||
const endDefault =
|
||||
event?.end
|
||||
? isoToInput(event.end, allDay)
|
||||
: defaultDate
|
||||
? allDay
|
||||
? defaultDate
|
||||
: `${defaultDate}T10:00`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
|
||||
<SheetHeader className="border-b px-6 py-4">
|
||||
<SheetTitle>{isEdit ? "Etkinliği düzenle" : "Yeni etkinlik"}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Tarih, saat ve müşteri bilgileri ile bir takvim girdisi oluşturun.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<form
|
||||
action={(fd) => {
|
||||
["customerId", "color"].forEach((k) => {
|
||||
if (fd.get(k) === NONE) fd.set(k, "");
|
||||
});
|
||||
formAction(fd);
|
||||
}}
|
||||
className="flex flex-1 flex-col"
|
||||
>
|
||||
{isEdit && event && <input type="hidden" name="id" value={event.id} />}
|
||||
|
||||
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title">Başlık *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
defaultValue={event?.title ?? ""}
|
||||
placeholder="Örn. Müşteri toplantısı"
|
||||
required
|
||||
/>
|
||||
{state.fieldErrors?.title && (
|
||||
<p className="text-destructive text-xs">{state.fieldErrors.title}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="grid gap-0.5">
|
||||
<Label htmlFor="allDay" className="cursor-pointer">
|
||||
Tüm gün
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">Saat girmeden gün boyu sürecek.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="allDay"
|
||||
name="allDay"
|
||||
checked={allDay}
|
||||
onCheckedChange={setAllDay}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="start">Başlangıç *</Label>
|
||||
<Input
|
||||
id="start"
|
||||
name="start"
|
||||
type={allDay ? "date" : "datetime-local"}
|
||||
defaultValue={startDefault}
|
||||
required
|
||||
/>
|
||||
{state.fieldErrors?.start && (
|
||||
<p className="text-destructive text-xs">{state.fieldErrors.start}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="end">Bitiş *</Label>
|
||||
<Input
|
||||
id="end"
|
||||
name="end"
|
||||
type={allDay ? "date" : "datetime-local"}
|
||||
defaultValue={endDefault}
|
||||
required
|
||||
/>
|
||||
{state.fieldErrors?.end && (
|
||||
<p className="text-destructive text-xs">{state.fieldErrors.end}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="customerId">Müşteri (opsiyonel)</Label>
|
||||
<Select name="customerId" defaultValue={event?.customerId || NONE}>
|
||||
<SelectTrigger id="customerId">
|
||||
<SelectValue placeholder="Yok" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>Yok</SelectItem>
|
||||
{customers.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="color">Renk</Label>
|
||||
<Select name="color" defaultValue={event?.color || NONE}>
|
||||
<SelectTrigger id="color">
|
||||
<SelectValue placeholder="Varsayılan" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>Varsayılan</SelectItem>
|
||||
{COLOR_PRESETS.map((c) => (
|
||||
<SelectItem key={c.value} value={c.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className={cn("size-3 rounded-full", c.classes)} />
|
||||
{c.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">Notlar</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows={3}
|
||||
defaultValue={event?.description ?? ""}
|
||||
placeholder="Açıklama, gündem, vb."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div>
|
||||
{isEdit && event && onRequestDelete && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => onRequestDelete(event)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Sil
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Vazgeç
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Kaydediliyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="size-4" />
|
||||
{isEdit ? "Güncelle" : "Kaydet"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type EventRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
start: string;
|
||||
end: string;
|
||||
allDay: boolean;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export type Customer = { id: string; name: string };
|
||||
|
||||
export const COLOR_PRESETS = [
|
||||
{ value: "blue", label: "Mavi", classes: "bg-blue-500" },
|
||||
{ value: "green", label: "Yeşil", classes: "bg-emerald-500" },
|
||||
{ value: "amber", label: "Amber", classes: "bg-amber-500" },
|
||||
{ value: "red", label: "Kırmızı", classes: "bg-red-500" },
|
||||
{ value: "violet", label: "Mor", classes: "bg-violet-500" },
|
||||
{ value: "slate", label: "Gri", classes: "bg-slate-500" },
|
||||
] as const;
|
||||
|
||||
export const COLOR_BG: Record<string, string> = {
|
||||
blue: "bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30",
|
||||
green: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30",
|
||||
amber: "bg-amber-500/15 text-amber-800 dark:text-amber-300 border-amber-500/30",
|
||||
red: "bg-red-500/15 text-red-700 dark:text-red-300 border-red-500/30",
|
||||
violet: "bg-violet-500/15 text-violet-700 dark:text-violet-300 border-violet-500/30",
|
||||
slate: "bg-slate-500/15 text-slate-700 dark:text-slate-300 border-slate-500/30",
|
||||
"": "bg-primary/10 text-primary border-primary/20",
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { listCalendarEvents } from "@/lib/appwrite/calendar-queries";
|
||||
import { listCustomers } from "@/lib/appwrite/customer-queries";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { CalendarClient } from "./components/calendar-client";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "İşletmem — Takvim",
|
||||
};
|
||||
|
||||
export default async function CalendarPage() {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
redirect("/onboarding");
|
||||
}
|
||||
|
||||
const [events, customers] = await Promise.all([
|
||||
listCalendarEvents(ctx.tenantId),
|
||||
listCustomers(ctx.tenantId),
|
||||
]);
|
||||
|
||||
const customerMap = new Map(customers.map((c) => [c.$id, c.name]));
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-6 px-6 pt-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Takvim</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Toplantılar, randevular ve önemli tarihler.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CalendarClient
|
||||
events={events.map((e) => ({
|
||||
id: e.$id,
|
||||
title: e.title,
|
||||
description: e.description ?? "",
|
||||
start: e.start,
|
||||
end: e.end,
|
||||
allDay: Boolean(e.allDay),
|
||||
customerId: e.customerId ?? "",
|
||||
customerName: e.customerId ? customerMap.get(e.customerId) ?? "" : "",
|
||||
color: e.color ?? "",
|
||||
}))}
|
||||
customers={customers.map((c) => ({ id: c.$id, name: c.name }))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Phone,
|
||||
Video,
|
||||
Info,
|
||||
Search,
|
||||
MoreVertical,
|
||||
Users,
|
||||
Bell,
|
||||
BellOff
|
||||
} from "lucide-react"
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@/components/ui/tooltip"
|
||||
import { type Conversation, type User } from "../use-chat"
|
||||
|
||||
interface ChatHeaderProps {
|
||||
conversation: Conversation | null
|
||||
users: User[]
|
||||
onToggleMute?: () => void
|
||||
onToggleInfo?: () => void
|
||||
}
|
||||
|
||||
export function ChatHeader({
|
||||
conversation,
|
||||
users,
|
||||
onToggleMute,
|
||||
onToggleInfo
|
||||
}: ChatHeaderProps) {
|
||||
if (!conversation) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-muted-foreground">Select a conversation to start chatting</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getConversationUsers = () => {
|
||||
if (conversation.type === "direct") {
|
||||
return users.filter(user => conversation.participants.includes(user.id))
|
||||
}
|
||||
return users.filter(user => conversation.participants.includes(user.id))
|
||||
}
|
||||
|
||||
const conversationUsers = getConversationUsers()
|
||||
const primaryUser = conversationUsers[0]
|
||||
|
||||
const getStatusText = () => {
|
||||
if (conversation.type === "group") {
|
||||
const onlineCount = conversationUsers.filter(user => user.status === "online").length
|
||||
return `${conversation.participants.length} members, ${onlineCount} online`
|
||||
} else if (primaryUser) {
|
||||
switch (primaryUser.status) {
|
||||
case "online":
|
||||
return "Active now"
|
||||
case "away":
|
||||
return "Away"
|
||||
case "offline":
|
||||
return `Last seen ${new Date(primaryUser.lastSeen).toLocaleDateString()}`
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (conversation.type === "group") return "text-muted-foreground"
|
||||
|
||||
switch (primaryUser?.status) {
|
||||
case "online":
|
||||
return "text-green-600"
|
||||
case "away":
|
||||
return "text-yellow-600"
|
||||
case "offline":
|
||||
return "text-muted-foreground"
|
||||
default:
|
||||
return "text-muted-foreground"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between h-full">
|
||||
{/* Left side - Avatar and info */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10 cursor-pointer">
|
||||
<AvatarImage src={conversation.avatar} alt={conversation.name} />
|
||||
<AvatarFallback>
|
||||
{conversation.type === "group" ? (
|
||||
<Users className="h-5 w-5" />
|
||||
) : (
|
||||
conversation.name.split(' ').map(n => n[0]).join('').slice(0, 2)
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-semibold truncate">{conversation.name}</h2>
|
||||
{conversation.isMuted && (
|
||||
<BellOff className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
{conversation.type === "group" && (
|
||||
<Badge variant="secondary" className="text-xs cursor-pointer">
|
||||
Group
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className={`text-sm ${getStatusColor()}`}>
|
||||
{getStatusText()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Action buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider>
|
||||
{/* Search */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="cursor-pointer">
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Search in conversation</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Phone call */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="cursor-pointer">
|
||||
<Phone className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Voice call</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Video call */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="cursor-pointer">
|
||||
<Video className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Video call</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Info */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggleInfo}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Conversation info</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* More options */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="cursor-pointer">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={onToggleMute}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{conversation.isMuted ? (
|
||||
<>
|
||||
<Bell className="h-4 w-4 mr-2" />
|
||||
Unmute conversation
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BellOff className="h-4 w-4 mr-2" />
|
||||
Mute conversation
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
Search messages
|
||||
</DropdownMenuItem>
|
||||
{conversation.type === "group" && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
Manage members
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="cursor-pointer text-destructive">
|
||||
Delete conversation
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Menu, X } from "lucide-react"
|
||||
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ConversationList } from "./conversation-list"
|
||||
import { ChatHeader } from "./chat-header"
|
||||
import { MessageList } from "./message-list"
|
||||
import { MessageInput } from "./message-input"
|
||||
import { useChat, type Conversation, type Message, type User } from "../use-chat"
|
||||
|
||||
interface ChatProps {
|
||||
conversations: Conversation[]
|
||||
messages: Record<string, Message[]>
|
||||
users: User[]
|
||||
}
|
||||
|
||||
export function Chat({
|
||||
conversations,
|
||||
messages,
|
||||
users,
|
||||
}: ChatProps) {
|
||||
const {
|
||||
selectedConversation,
|
||||
setSelectedConversation,
|
||||
setConversations,
|
||||
setMessages,
|
||||
setUsers,
|
||||
addMessage,
|
||||
toggleMute,
|
||||
} = useChat()
|
||||
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
||||
|
||||
// Close sidebar when clicking outside on mobile
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (typeof window !== "undefined" ? window.innerWidth : 0 >= 1024) { // lg breakpoint
|
||||
setIsSidebarOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener('resize', handleResize)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Initialize data
|
||||
useEffect(() => {
|
||||
setConversations(conversations)
|
||||
setUsers(users)
|
||||
|
||||
// Set messages for all conversations
|
||||
Object.entries(messages).forEach(([conversationId, conversationMessages]) => {
|
||||
setMessages(conversationId, conversationMessages)
|
||||
})
|
||||
|
||||
// Auto-select first conversation if none selected
|
||||
if (!selectedConversation && conversations.length > 0) {
|
||||
setSelectedConversation(conversations[0].id)
|
||||
}
|
||||
}, [conversations, messages, users, selectedConversation, setConversations, setMessages, setUsers, setSelectedConversation])
|
||||
|
||||
const currentConversation = conversations.find(conv => conv.id === selectedConversation)
|
||||
const currentMessages = selectedConversation ? messages[selectedConversation] || [] : []
|
||||
|
||||
const handleSendMessage = (content: string) => {
|
||||
if (!selectedConversation) return
|
||||
|
||||
const newMessage = {
|
||||
id: `msg-${Date.now()}`,
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
senderId: "current-user",
|
||||
type: "text" as const,
|
||||
isEdited: false,
|
||||
reactions: [],
|
||||
replyTo: null,
|
||||
}
|
||||
|
||||
addMessage(selectedConversation, newMessage)
|
||||
}
|
||||
|
||||
const handleToggleMute = () => {
|
||||
if (selectedConversation) {
|
||||
toggleMute(selectedConversation)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className="h-full min-h-[600px] max-h-[calc(100vh-200px)] flex rounded-lg border overflow-hidden bg-background">
|
||||
{/* Mobile Sidebar Overlay */}
|
||||
{isSidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Conversations Sidebar - Responsive */}
|
||||
<div className={`
|
||||
w-100 border-r bg-background flex-shrink-0
|
||||
${isSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||
lg:relative lg:block
|
||||
fixed inset-y-0 left-0 z-50
|
||||
transition-transform duration-300 ease-in-out
|
||||
`}>
|
||||
{/* Sidebar Header with Close Button (Mobile Only) */}
|
||||
<div className="lg:hidden p-4 border-b flex items-center justify-between bg-background">
|
||||
<h2 className="text-lg font-semibold">Messages</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ConversationList
|
||||
conversations={conversations}
|
||||
selectedConversation={selectedConversation}
|
||||
onSelectConversation={(id) => {
|
||||
setSelectedConversation(id)
|
||||
setIsSidebarOpen(false) // Close sidebar on mobile after selection
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chat Panel - Flexible Width */}
|
||||
<div className="flex-1 flex flex-col min-w-0 bg-background">
|
||||
{/* Chat Header with Hamburger Menu */}
|
||||
<div className="flex items-center h-16 px-4 border-b bg-background">
|
||||
{/* Hamburger Menu Button - Only visible when sidebar is hidden on mobile */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsSidebarOpen(true)}
|
||||
className="cursor-pointer lg:hidden mr-2"
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex-1">
|
||||
<ChatHeader
|
||||
conversation={currentConversation || null}
|
||||
users={users}
|
||||
onToggleMute={handleToggleMute}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{selectedConversation ? (
|
||||
<>
|
||||
<MessageList
|
||||
messages={currentMessages}
|
||||
users={users}
|
||||
/>
|
||||
|
||||
{/* Message Input */}
|
||||
<MessageInput
|
||||
onSendMessage={handleSendMessage}
|
||||
placeholder={`Message ${currentConversation?.name || ""}...`}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold mb-2">Welcome to Chat</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Select a conversation to start messaging
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client"
|
||||
|
||||
import { format, isToday, isYesterday, isThisWeek, isThisYear } from "date-fns"
|
||||
import {
|
||||
Search,
|
||||
Pin,
|
||||
VolumeX,
|
||||
MoreHorizontal,
|
||||
Users,
|
||||
Hash
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useChat, type Conversation } from "../use-chat"
|
||||
|
||||
interface ConversationListProps {
|
||||
conversations: Conversation[]
|
||||
selectedConversation: string | null
|
||||
onSelectConversation: (conversationId: string) => void
|
||||
}
|
||||
|
||||
// Enhanced time formatting function
|
||||
function formatMessageTime(timestamp: string): string {
|
||||
const date = new Date(timestamp)
|
||||
|
||||
if (isToday(date)) {
|
||||
return format(date, 'h:mm a') // 3:30 PM
|
||||
} else if (isYesterday(date)) {
|
||||
return 'Yesterday'
|
||||
} else if (isThisWeek(date)) {
|
||||
return format(date, 'EEEE') // Day name
|
||||
} else if (isThisYear(date)) {
|
||||
return format(date, 'MMM d') // Jan 15
|
||||
} else {
|
||||
return format(date, 'dd/MM/yy') // 15/01/24
|
||||
}
|
||||
}
|
||||
|
||||
export function ConversationList({
|
||||
conversations,
|
||||
selectedConversation,
|
||||
onSelectConversation
|
||||
}: ConversationListProps) {
|
||||
const { searchQuery, setSearchQuery, togglePin, toggleMute } = useChat()
|
||||
|
||||
const filteredConversations = conversations.filter((conversation) =>
|
||||
conversation.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
const sortedConversations = filteredConversations.sort((a, b) => {
|
||||
// Pinned conversations first
|
||||
if (a.isPinned && !b.isPinned) return -1
|
||||
if (!a.isPinned && b.isPinned) return 1
|
||||
|
||||
// Then by last message timestamp
|
||||
return new Date(b.lastMessage.timestamp).getTime() - new Date(a.lastMessage.timestamp).getTime()
|
||||
})
|
||||
|
||||
const getOnlineStatus = (conversation: Conversation) => {
|
||||
if (conversation.type === "direct" && conversation.participants.length === 1) {
|
||||
// In a real app, you'd check user online status
|
||||
return Math.random() > 0.5 // Mock online status
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold">Messages</h2>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="p-4 border-b flex-shrink-0">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search conversations..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 cursor-text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conversations */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2">
|
||||
{sortedConversations.map((conversation) => (
|
||||
<div
|
||||
key={conversation.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-3 rounded-lg cursor-pointer relative group overflow-hidden hover:bg-accent/50 transition-colors",
|
||||
selectedConversation === conversation.id
|
||||
? "bg-accent text-accent-foreground"
|
||||
: ""
|
||||
)}
|
||||
onClick={() => onSelectConversation(conversation.id)}
|
||||
>
|
||||
{/* Avatar with online indicator */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<Avatar className={cn(
|
||||
"h-12 w-12",
|
||||
selectedConversation === conversation.id && "ring-2 ring-background"
|
||||
)}>
|
||||
<AvatarImage src={conversation.avatar} alt={conversation.name} />
|
||||
<AvatarFallback className="text-sm">
|
||||
{conversation.type === "group" ? (
|
||||
<Users className="h-5 w-5" />
|
||||
) : (
|
||||
conversation.name.split(' ').map(n => n[0]).join('').slice(0, 2)
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
{/* Online indicator for direct messages */}
|
||||
{conversation.type === "direct" && getOnlineStatus(conversation) && (
|
||||
<div className="absolute -bottom-1 -right-1 h-4 w-4 bg-green-500 border-2 border-background rounded-full" />
|
||||
)}
|
||||
|
||||
{/* Group indicator */}
|
||||
{conversation.type === "group" && (
|
||||
<div className="absolute -bottom-1 -right-1 h-4 w-4 bg-blue-500 border-2 border-background rounded-full flex items-center justify-center">
|
||||
<Hash className="h-2 w-2 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center justify-between mb-1 min-w-0">
|
||||
<div className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
|
||||
<h3 className="font-medium truncate min-w-0 max-w-[180px]">{conversation.name}</h3>
|
||||
{conversation.isPinned && (
|
||||
<Pin className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
{conversation.isMuted && (
|
||||
<VolumeX className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2 whitespace-nowrap">
|
||||
{formatMessageTime(conversation.lastMessage.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||
<p className="text-sm text-muted-foreground truncate flex-1 min-w-0 max-w-[200px]">
|
||||
{conversation.lastMessage.content}
|
||||
</p>
|
||||
|
||||
{/* Unread count */}
|
||||
{conversation.unreadCount > 0 && (
|
||||
<Badge variant="default" className="ml-2 min-w-[20px] h-5 text-xs cursor-pointer flex-shrink-0">
|
||||
{conversation.unreadCount > 99 ? "99+" : conversation.unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions menu */}
|
||||
<div className="opacity-0 group-hover:opacity-100 ml-2 flex-shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 cursor-pointer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
togglePin(conversation.id)
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Pin className="h-4 w-4 mr-2" />
|
||||
{conversation.isPinned ? "Unpin" : "Pin"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleMute(conversation.id)
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<VolumeX className="h-4 w-4 mr-2" />
|
||||
{conversation.isMuted ? "Unmute" : "Mute"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="cursor-pointer text-destructive">
|
||||
Delete conversation
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
"use client"
|
||||
|
||||
import { format, isToday, isYesterday, isThisWeek, isThisYear } from "date-fns"
|
||||
import {
|
||||
Search,
|
||||
Pin,
|
||||
VolumeX,
|
||||
MoreVertical,
|
||||
Users,
|
||||
Hash,
|
||||
Settings,
|
||||
UserPlus,
|
||||
Filter
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useChat, type Conversation } from "../use-chat"
|
||||
|
||||
interface ConversationListProps {
|
||||
conversations: Conversation[]
|
||||
selectedConversation: string | null
|
||||
onSelectConversation: (conversationId: string) => void
|
||||
}
|
||||
|
||||
// Enhanced time formatting function
|
||||
function formatMessageTime(timestamp: string): string {
|
||||
const date = new Date(timestamp)
|
||||
|
||||
if (isToday(date)) {
|
||||
return format(date, 'h:mm a') // 3:30 PM
|
||||
} else if (isYesterday(date)) {
|
||||
return 'Yesterday'
|
||||
} else if (isThisWeek(date)) {
|
||||
return format(date, 'EEEE') // Day name
|
||||
} else if (isThisYear(date)) {
|
||||
return format(date, 'MMM d') // Jan 15
|
||||
} else {
|
||||
return format(date, 'dd/MM/yy') // 15/01/24
|
||||
}
|
||||
}
|
||||
|
||||
export function ConversationList({
|
||||
conversations,
|
||||
selectedConversation,
|
||||
onSelectConversation
|
||||
}: ConversationListProps) {
|
||||
const { searchQuery, setSearchQuery } = useChat()
|
||||
|
||||
const filteredConversations = conversations.filter((conversation) =>
|
||||
conversation.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
const sortedConversations = filteredConversations.sort((a, b) => {
|
||||
// Pinned conversations first
|
||||
if (a.isPinned && !b.isPinned) return -1
|
||||
if (!a.isPinned && b.isPinned) return 1
|
||||
|
||||
// Then by last message timestamp
|
||||
return new Date(b.lastMessage.timestamp).getTime() - new Date(a.lastMessage.timestamp).getTime()
|
||||
})
|
||||
|
||||
const getOnlineStatus = (conversation: Conversation) => {
|
||||
if (conversation.type === "direct" && conversation.participants.length === 1) {
|
||||
// In a real app, you'd check user online status
|
||||
return Math.random() > 0.5 // Mock online status
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Header - Hidden on mobile (handled by parent) */}
|
||||
<div className="hidden lg:flex items-center justify-between h-16 px-4 border-b flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold">Messages</h2>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 cursor-pointer"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
New Chat
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Filter Messages
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Chat Settings
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-4 py-3 border-b flex-shrink-0">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search conversations..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 cursor-text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conversations */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2">
|
||||
{sortedConversations.map((conversation) => (
|
||||
<div
|
||||
key={conversation.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-3 rounded-lg cursor-pointer relative overflow-hidden hover:bg-accent/50 transition-colors",
|
||||
selectedConversation === conversation.id
|
||||
? "bg-accent text-accent-foreground"
|
||||
: ""
|
||||
)}
|
||||
onClick={() => onSelectConversation(conversation.id)}
|
||||
>
|
||||
{/* Avatar with online indicator */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<Avatar className={cn(
|
||||
"h-12 w-12",
|
||||
selectedConversation === conversation.id && "ring-2 ring-background"
|
||||
)}>
|
||||
<AvatarImage src={conversation.avatar} alt={conversation.name} />
|
||||
<AvatarFallback className="text-sm">
|
||||
{conversation.type === "group" ? (
|
||||
<Users className="h-5 w-5" />
|
||||
) : (
|
||||
conversation.name.split(' ').map(n => n[0]).join('').slice(0, 2)
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
{/* Online indicator for direct messages */}
|
||||
{conversation.type === "direct" && getOnlineStatus(conversation) && (
|
||||
<div className="absolute -bottom-1 -right-1 h-4 w-4 bg-green-500 border-2 border-background rounded-full" />
|
||||
)}
|
||||
|
||||
{/* Group indicator */}
|
||||
{conversation.type === "group" && (
|
||||
<div className="absolute -bottom-1 -right-1 h-4 w-4 bg-blue-500 border-2 border-background rounded-full flex items-center justify-center">
|
||||
<Hash className="h-2 w-2 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center justify-between mb-1 min-w-0">
|
||||
<div className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden pr-2">
|
||||
<h3 className="font-medium truncate min-w-0 max-w-[160px] lg:max-w-[180px]">{conversation.name}</h3>
|
||||
{conversation.isPinned && (
|
||||
<Pin className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
{conversation.isMuted && (
|
||||
<VolumeX className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 whitespace-nowrap">
|
||||
{formatMessageTime(conversation.lastMessage.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||
<p className="text-sm text-muted-foreground truncate flex-1 min-w-0 max-w-[180px] lg:max-w-[200px] pr-2">
|
||||
{conversation.lastMessage.content}
|
||||
</p>
|
||||
|
||||
{/* Unread count */}
|
||||
{conversation.unreadCount > 0 && (
|
||||
<Badge variant="default" className="min-w-[20px] h-5 text-xs cursor-pointer flex-shrink-0">
|
||||
{conversation.unreadCount > 99 ? "99+" : conversation.unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import {
|
||||
Send,
|
||||
Paperclip,
|
||||
Smile,
|
||||
Image as ImageIcon,
|
||||
FileText,
|
||||
Mic,
|
||||
MoreHorizontal
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
interface MessageInputProps {
|
||||
onSendMessage: (content: string) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function MessageInput({
|
||||
onSendMessage,
|
||||
disabled = false,
|
||||
placeholder = "Type a message..."
|
||||
}: MessageInputProps) {
|
||||
const [message, setMessage] = useState("")
|
||||
const [isTyping, setIsTyping] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const handleSendMessage = () => {
|
||||
const trimmedMessage = message.trim()
|
||||
if (trimmedMessage && !disabled) {
|
||||
onSendMessage(trimmedMessage)
|
||||
setMessage("")
|
||||
setIsTyping(false)
|
||||
|
||||
// Reset textarea height
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value
|
||||
setMessage(value)
|
||||
|
||||
// Auto-resize textarea
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto"
|
||||
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`
|
||||
}
|
||||
|
||||
// Handle typing indicator
|
||||
if (value.trim() && !isTyping) {
|
||||
setIsTyping(true)
|
||||
} else if (!value.trim() && isTyping) {
|
||||
setIsTyping(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = (type: "image" | "file") => {
|
||||
// In a real app, this would open a file picker
|
||||
console.log(`Upload ${type}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t p-4">
|
||||
<div className="flex items-end gap-2">
|
||||
{/* Attachment button */}
|
||||
<TooltipProvider>
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
className="cursor-pointer disabled:cursor-not-allowed"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Attach file</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleFileUpload("image")}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<ImageIcon className="h-4 w-4 mr-2" />
|
||||
Photo or video
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleFileUpload("file")}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Document
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Message input */}
|
||||
<div className="flex-1 relative">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
placeholder={placeholder}
|
||||
value={message}
|
||||
onChange={handleTextareaChange}
|
||||
onKeyDown={handleKeyPress}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"min-h-[40px] max-h-[120px] resize-none cursor-text disabled:cursor-not-allowed",
|
||||
"pr-20" // Space for emoji and more buttons
|
||||
)}
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
{/* Input action buttons */}
|
||||
<div className="absolute right-2 bottom-2 flex items-center gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className="h-6 w-6 p-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
>
|
||||
<Smile className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Add emoji</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className="h-6 w-6 p-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>More options</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Voice message or send button */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{message.trim() ? (
|
||||
<Button
|
||||
onClick={handleSendMessage}
|
||||
disabled={disabled}
|
||||
className="cursor-pointer disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
className="cursor-pointer disabled:cursor-not-allowed"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{message.trim() ? "Send message" : "Voice message"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
{/* Typing indicator */}
|
||||
{isTyping && (
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
You are typing...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import { format, isToday, isYesterday } from "date-fns"
|
||||
import { CheckCheck, MoreHorizontal, Reply, Copy, Trash2 } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { type Message, type User } from "../use-chat"
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[]
|
||||
users: User[]
|
||||
currentUserId?: string
|
||||
}
|
||||
|
||||
export function MessageList({ messages, users, currentUserId = "current-user" }: MessageListProps) {
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const previousMessageCountRef = useRef(0)
|
||||
const isInitialLoadRef = useRef(true)
|
||||
const previousConversationRef = useRef<string | null>(null)
|
||||
|
||||
// Reset scroll behavior when switching conversations
|
||||
useEffect(() => {
|
||||
const currentConversationId = messages.length > 0 ? messages[0]?.id?.split('-')[0] : null
|
||||
if (currentConversationId !== previousConversationRef.current) {
|
||||
isInitialLoadRef.current = true
|
||||
previousConversationRef.current = currentConversationId
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
// Auto-scroll to bottom only when new messages are added (not on initial load)
|
||||
useEffect(() => {
|
||||
// Skip auto-scroll on initial load
|
||||
if (isInitialLoadRef.current) {
|
||||
isInitialLoadRef.current = false
|
||||
previousMessageCountRef.current = messages.length
|
||||
return
|
||||
}
|
||||
|
||||
// Only auto-scroll if new messages were added
|
||||
if (messages.length > previousMessageCountRef.current && bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
|
||||
previousMessageCountRef.current = messages.length
|
||||
}, [messages])
|
||||
|
||||
const getUserById = (userId: string) => {
|
||||
if (userId === currentUserId) {
|
||||
return {
|
||||
id: currentUserId,
|
||||
name: "You",
|
||||
avatar: "https://notion-avatars.netlify.app/api/avatar/?preset=male-7",
|
||||
status: "online" as const,
|
||||
email: "you@example.com",
|
||||
lastSeen: new Date().toISOString(),
|
||||
role: "Developer",
|
||||
department: "Engineering"
|
||||
}
|
||||
}
|
||||
return users.find(user => user.id === userId)
|
||||
}
|
||||
|
||||
const formatMessageTime = (timestamp: string) => {
|
||||
const date = new Date(timestamp)
|
||||
if (isToday(date)) {
|
||||
return format(date, "HH:mm")
|
||||
} else if (isYesterday(date)) {
|
||||
return `Yesterday ${format(date, "HH:mm")}`
|
||||
} else {
|
||||
return format(date, "MMM d, HH:mm")
|
||||
}
|
||||
}
|
||||
|
||||
const shouldShowAvatar = (message: Message, index: number) => {
|
||||
if (message.senderId === currentUserId) return false
|
||||
if (index === 0) return true
|
||||
|
||||
const prevMessage = messages[index - 1]
|
||||
return prevMessage.senderId !== message.senderId
|
||||
}
|
||||
|
||||
const shouldShowName = (message: Message, index: number) => {
|
||||
if (message.senderId === currentUserId) return false
|
||||
if (index === 0) return true
|
||||
|
||||
const prevMessage = messages[index - 1]
|
||||
return prevMessage.senderId !== message.senderId
|
||||
}
|
||||
|
||||
const isConsecutiveMessage = (message: Message, index: number) => {
|
||||
if (index === 0) return false
|
||||
|
||||
const prevMessage = messages[index - 1]
|
||||
const timeDiff = new Date(message.timestamp).getTime() - new Date(prevMessage.timestamp).getTime()
|
||||
|
||||
return prevMessage.senderId === message.senderId && timeDiff < 5 * 60 * 1000 // 5 minutes
|
||||
}
|
||||
|
||||
const groupMessagesByDay = (messages: Message[]) => {
|
||||
const groups: { date: string; messages: Message[] }[] = []
|
||||
|
||||
messages.forEach((message) => {
|
||||
const messageDate = format(new Date(message.timestamp), "yyyy-MM-dd")
|
||||
const lastGroup = groups[groups.length - 1]
|
||||
|
||||
if (lastGroup && lastGroup.date === messageDate) {
|
||||
lastGroup.messages.push(message)
|
||||
} else {
|
||||
groups.push({
|
||||
date: messageDate,
|
||||
messages: [message]
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
const formatDateHeader = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
if (isToday(date)) {
|
||||
return "Today"
|
||||
} else if (isYesterday(date)) {
|
||||
return "Yesterday"
|
||||
} else {
|
||||
return format(date, "EEEE, MMMM d")
|
||||
}
|
||||
}
|
||||
|
||||
const messageGroups = groupMessagesByDay(messages)
|
||||
|
||||
return (
|
||||
<ScrollArea className="flex-1 px-4" ref={scrollAreaRef}>
|
||||
<div className="space-y-4 py-4">
|
||||
{messageGroups.map((group) => (
|
||||
<div key={group.date}>
|
||||
{/* Date separator */}
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<div className="text-xs text-muted-foreground bg-background px-3 py-1 rounded-full border">
|
||||
{formatDateHeader(group.date)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages for this day */}
|
||||
<div className="space-y-1">
|
||||
{group.messages.map((message, messageIndex) => {
|
||||
const user = getUserById(message.senderId)
|
||||
const isOwnMessage = message.senderId === currentUserId
|
||||
const showAvatar = shouldShowAvatar(message, messageIndex)
|
||||
const showName = shouldShowName(message, messageIndex)
|
||||
const isConsecutive = isConsecutiveMessage(message, messageIndex)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"flex gap-3 group",
|
||||
isOwnMessage && "flex-row-reverse",
|
||||
isConsecutive && !isOwnMessage && "ml-12"
|
||||
)}
|
||||
>
|
||||
{/* Avatar */}
|
||||
{!isOwnMessage && (
|
||||
<div className="w-8">
|
||||
{showAvatar && user && (
|
||||
<Avatar className="h-8 w-8 cursor-pointer">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="text-xs">
|
||||
{user.name.split(' ').map(n => n[0]).join('').slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message content */}
|
||||
<div className={cn("flex-1 max-w-[70%]", isOwnMessage && "flex flex-col items-end")}>
|
||||
{/* Sender name for group messages */}
|
||||
{showName && user && !isOwnMessage && (
|
||||
<div className="text-sm font-medium text-foreground mb-1">
|
||||
{user.name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message bubble */}
|
||||
<div className="relative group/message">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-2 text-sm break-words",
|
||||
isOwnMessage
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted",
|
||||
isConsecutive && "mt-1"
|
||||
)}
|
||||
>
|
||||
<p>{message.content}</p>
|
||||
|
||||
{/* Message reactions */}
|
||||
{message.reactions.length > 0 && (
|
||||
<div className="flex gap-1 mt-2">
|
||||
{message.reactions.map((reaction, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs border cursor-pointer",
|
||||
"bg-background/90 backdrop-blur-sm shadow-sm"
|
||||
)}
|
||||
>
|
||||
<span>{reaction.emoji}</span>
|
||||
<span className="text-muted-foreground">{reaction.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamp and status */}
|
||||
<div className={cn(
|
||||
"flex items-center gap-1 mt-1 text-xs",
|
||||
isOwnMessage
|
||||
? "text-primary-foreground/70 justify-end"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
<span>{formatMessageTime(message.timestamp)}</span>
|
||||
{message.isEdited && (
|
||||
<span className="italic">(edited)</span>
|
||||
)}
|
||||
{isOwnMessage && (
|
||||
<div className="flex">
|
||||
{/* Message status indicators */}
|
||||
<CheckCheck className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message actions */}
|
||||
<div className="absolute top-0 right-0 opacity-0 group-hover/message:opacity-100">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 cursor-pointer"
|
||||
>
|
||||
<MoreHorizontal className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<Reply className="h-4 w-4 mr-2" />
|
||||
Reply
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer">
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy
|
||||
</DropdownMenuItem>
|
||||
{isOwnMessage && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="cursor-pointer text-destructive">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Scroll anchor */}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
[
|
||||
{
|
||||
"id": "conv-1",
|
||||
"type": "direct",
|
||||
"participants": ["1"],
|
||||
"name": "Sarah Mitchell",
|
||||
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-7",
|
||||
"lastMessage": {
|
||||
"id": "msg-1-4",
|
||||
"content": "Thanks for the quick update! The dashboard looks amazing 🎉",
|
||||
"timestamp": "2025-08-11T15:30:00Z",
|
||||
"senderId": "1"
|
||||
},
|
||||
"unreadCount": 2,
|
||||
"isPinned": true,
|
||||
"isMuted": false
|
||||
},
|
||||
{
|
||||
"id": "conv-2",
|
||||
"type": "group",
|
||||
"participants": ["2", "3", "5"],
|
||||
"name": "Project Alpha",
|
||||
"lastMessage": {
|
||||
"id": "msg-2-8",
|
||||
"content": "David: Marketing campaign is scheduled for next week",
|
||||
"timestamp": "2025-08-11T08:15:00Z",
|
||||
"senderId": "2"
|
||||
},
|
||||
"unreadCount": 0,
|
||||
"isPinned": false,
|
||||
"isMuted": false
|
||||
},
|
||||
{
|
||||
"id": "conv-3",
|
||||
"type": "group",
|
||||
"participants": ["2", "3", "5"],
|
||||
"name": "Frontend Team",
|
||||
"lastMessage": {
|
||||
"id": "msg-3-6",
|
||||
"content": "Alex: The new component library is ready for testing",
|
||||
"timestamp": "2025-08-11T23:45:00Z",
|
||||
"senderId": "3"
|
||||
},
|
||||
"unreadCount": 1,
|
||||
"isPinned": false,
|
||||
"isMuted": false
|
||||
},
|
||||
{
|
||||
"id": "conv-4",
|
||||
"type": "direct",
|
||||
"participants": ["3"],
|
||||
"name": "Emily Rodriguez",
|
||||
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-2",
|
||||
"lastMessage": {
|
||||
"id": "msg-4-3",
|
||||
"content": "Let's review the wireframes together tomorrow",
|
||||
"timestamp": "2025-08-10T16:30:00Z",
|
||||
"senderId": "3"
|
||||
},
|
||||
"unreadCount": 1,
|
||||
"isPinned": false,
|
||||
"isMuted": false
|
||||
},
|
||||
{
|
||||
"id": "conv-5",
|
||||
"type": "direct",
|
||||
"participants": ["5"],
|
||||
"name": "Lisa Chen",
|
||||
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-4",
|
||||
"lastMessage": {
|
||||
"id": "msg-5-3",
|
||||
"content": "Found a few edge cases in the new feature",
|
||||
"timestamp": "2025-08-06T14:20:00Z",
|
||||
"senderId": "5"
|
||||
},
|
||||
"unreadCount": 0,
|
||||
"isPinned": false,
|
||||
"isMuted": true
|
||||
},
|
||||
{
|
||||
"id": "conv-6",
|
||||
"type": "direct",
|
||||
"participants": ["2"],
|
||||
"name": "Alex Thompson",
|
||||
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=male-1",
|
||||
"lastMessage": {
|
||||
"id": "msg-6-3",
|
||||
"content": "Code review completed, looks good to merge! 👍",
|
||||
"timestamp": "2025-01-15T17:45:00Z",
|
||||
"senderId": "2"
|
||||
},
|
||||
"unreadCount": 0,
|
||||
"isPinned": false,
|
||||
"isMuted": false
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,224 @@
|
||||
{
|
||||
"conv-1": [
|
||||
{
|
||||
"id": "msg-1-1",
|
||||
"content": "Hey! How's the new dashboard coming along?",
|
||||
"timestamp": "2024-01-15T10:15:00Z",
|
||||
"senderId": "1",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-1-2",
|
||||
"content": "It's going great! We've implemented the new design system and it looks fantastic.",
|
||||
"timestamp": "2024-01-15T10:17:00Z",
|
||||
"senderId": "current-user",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [{"emoji": "👍", "users": ["1"], "count": 1}],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-1-3",
|
||||
"content": "That's awesome! Can you share a preview?",
|
||||
"timestamp": "2024-01-15T10:18:00Z",
|
||||
"senderId": "1",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-1-4",
|
||||
"content": "Thanks for the quick update! The dashboard looks amazing 🎉",
|
||||
"timestamp": "2024-01-15T10:30:00Z",
|
||||
"senderId": "1",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [{"emoji": "❤️", "users": ["current-user"], "count": 1}],
|
||||
"replyTo": null
|
||||
}
|
||||
],
|
||||
"conv-2": [
|
||||
{
|
||||
"id": "msg-2-1",
|
||||
"content": "Hey team! The component library update is ready",
|
||||
"timestamp": "2024-01-15T09:00:00Z",
|
||||
"senderId": "2",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-2-2",
|
||||
"content": "Awesome work Alex! 🚀",
|
||||
"timestamp": "2024-01-15T09:05:00Z",
|
||||
"senderId": "3",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-2-3",
|
||||
"content": "I've tested the new Button and Input components, they work perfectly",
|
||||
"timestamp": "2024-01-15T09:10:00Z",
|
||||
"senderId": "5",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [{"emoji": "✅", "users": ["2", "3"], "count": 2}],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-2-4",
|
||||
"content": "Great! I'll start integrating them into the main app",
|
||||
"timestamp": "2024-01-15T09:15:00Z",
|
||||
"senderId": "current-user",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
}
|
||||
],
|
||||
"conv-3": [
|
||||
{
|
||||
"id": "msg-3-1",
|
||||
"content": "Hi! I've completed the wireframes for the new user onboarding flow",
|
||||
"timestamp": "2024-01-15T09:30:00Z",
|
||||
"senderId": "3",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-3-2",
|
||||
"content": "That's fantastic Emily! When can we review them?",
|
||||
"timestamp": "2024-01-15T09:32:00Z",
|
||||
"senderId": "current-user",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-3-3",
|
||||
"content": "How about tomorrow at 2 PM? I'll share my screen and walk through the designs",
|
||||
"timestamp": "2024-01-15T09:35:00Z",
|
||||
"senderId": "3",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [{"emoji": "👍", "users": ["current-user"], "count": 1}],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-3-4",
|
||||
"content": "Perfect! Looking forward to it",
|
||||
"timestamp": "2024-01-15T09:40:00Z",
|
||||
"senderId": "current-user",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
}
|
||||
],
|
||||
"conv-4": [
|
||||
{
|
||||
"id": "msg-4-1",
|
||||
"content": "Hi! I've been working on the wireframes for the new feature",
|
||||
"timestamp": "2025-08-10T14:15:00Z",
|
||||
"senderId": "3",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-4-2",
|
||||
"content": "That's great! I'd love to take a look at them",
|
||||
"timestamp": "2025-08-10T14:18:00Z",
|
||||
"senderId": "current-user",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-4-3",
|
||||
"content": "Let's review the wireframes together tomorrow",
|
||||
"timestamp": "2025-08-10T16:30:00Z",
|
||||
"senderId": "3",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [{"emoji": "👍", "users": ["current-user"], "count": 1}],
|
||||
"replyTo": null
|
||||
}
|
||||
],
|
||||
"conv-5": [
|
||||
{
|
||||
"id": "msg-5-1",
|
||||
"content": "I've been testing the new feature and it looks good overall",
|
||||
"timestamp": "2025-08-06T13:45:00Z",
|
||||
"senderId": "5",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-5-2",
|
||||
"content": "Thanks for testing it! Any issues you found?",
|
||||
"timestamp": "2025-08-06T14:10:00Z",
|
||||
"senderId": "current-user",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-5-3",
|
||||
"content": "Found a few edge cases in the new feature",
|
||||
"timestamp": "2025-08-06T14:20:00Z",
|
||||
"senderId": "5",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
}
|
||||
],
|
||||
"conv-6": [
|
||||
{
|
||||
"id": "msg-6-1",
|
||||
"content": "Hey! I've finished the code review for the latest PR",
|
||||
"timestamp": "2025-01-15T16:30:00Z",
|
||||
"senderId": "2",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-6-2",
|
||||
"content": "Thanks for the quick review! Any feedback?",
|
||||
"timestamp": "2025-01-15T17:15:00Z",
|
||||
"senderId": "current-user",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [],
|
||||
"replyTo": null
|
||||
},
|
||||
{
|
||||
"id": "msg-6-3",
|
||||
"content": "Code review completed, looks good to merge! 👍",
|
||||
"timestamp": "2025-01-15T17:45:00Z",
|
||||
"senderId": "2",
|
||||
"type": "text",
|
||||
"isEdited": false,
|
||||
"reactions": [{"emoji": "🎉", "users": ["current-user"], "count": 1}],
|
||||
"replyTo": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
[
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Sarah Mitchell",
|
||||
"email": "sarah.mitchell@example.com",
|
||||
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-7",
|
||||
"status": "online",
|
||||
"lastSeen": "2024-01-15T10:30:00Z",
|
||||
"role": "Project Manager",
|
||||
"department": "Product"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "Alex Thompson",
|
||||
"email": "alex.thompson@example.com",
|
||||
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=male-1",
|
||||
"status": "away",
|
||||
"lastSeen": "2024-01-15T09:45:00Z",
|
||||
"role": "Senior Developer",
|
||||
"department": "Engineering"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"name": "Emily Rodriguez",
|
||||
"email": "emily.rodriguez@example.com",
|
||||
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-2",
|
||||
"status": "online",
|
||||
"lastSeen": "2024-01-15T10:25:00Z",
|
||||
"role": "UX Designer",
|
||||
"department": "Design"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"name": "David Kim",
|
||||
"email": "david.kim@example.com",
|
||||
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=male-5",
|
||||
"status": "offline",
|
||||
"lastSeen": "2024-01-14T18:30:00Z",
|
||||
"role": "Marketing Lead",
|
||||
"department": "Marketing"
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"name": "Lisa Chen",
|
||||
"email": "lisa.chen@example.com",
|
||||
"avatar": "https://notion-avatars.netlify.app/api/avatar/?preset=female-4",
|
||||
"status": "online",
|
||||
"lastSeen": "2024-01-15T10:20:00Z",
|
||||
"role": "QA Engineer",
|
||||
"department": "Engineering"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { Chat } from "./components/chat"
|
||||
import { type Conversation, type Message, type User } from "./use-chat"
|
||||
|
||||
// Import static data
|
||||
import conversationsData from "./data/conversations.json"
|
||||
import messagesData from "./data/messages.json"
|
||||
import usersData from "./data/users.json"
|
||||
|
||||
export default function ChatPage() {
|
||||
const [conversations, setConversations] = useState<Conversation[]>([])
|
||||
const [messages, setMessages] = useState<Record<string, Message[]>>({})
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// In a real app, these would be API calls
|
||||
setConversations(conversationsData as Conversation[])
|
||||
setMessages(messagesData as Record<string, Message[]>)
|
||||
setUsers(usersData as User[])
|
||||
} catch (error) {
|
||||
console.error("Failed to load chat data:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-muted-foreground">Loading chat...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 md:px-6">
|
||||
<Chat
|
||||
conversations={conversations}
|
||||
messages={messages}
|
||||
users={users}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client"
|
||||
|
||||
import { create } from "zustand"
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
avatar: string
|
||||
status: "online" | "away" | "offline"
|
||||
lastSeen: string
|
||||
role: string
|
||||
department: string
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
content: string
|
||||
timestamp: string
|
||||
senderId: string
|
||||
type: "text" | "image" | "file"
|
||||
isEdited: boolean
|
||||
reactions: Array<{
|
||||
emoji: string
|
||||
users: string[]
|
||||
count: number
|
||||
}>
|
||||
replyTo: string | null
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string
|
||||
type: "direct" | "group"
|
||||
participants: string[]
|
||||
name: string
|
||||
avatar: string
|
||||
lastMessage: {
|
||||
id: string
|
||||
content: string
|
||||
timestamp: string
|
||||
senderId: string
|
||||
}
|
||||
unreadCount: number
|
||||
isPinned: boolean
|
||||
isMuted: boolean
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
conversations: Conversation[]
|
||||
messages: Record<string, Message[]>
|
||||
users: User[]
|
||||
selectedConversation: string | null
|
||||
searchQuery: string
|
||||
isTyping: Record<string, boolean>
|
||||
onlineUsers: string[]
|
||||
}
|
||||
|
||||
interface ChatActions {
|
||||
setConversations: (conversations: Conversation[]) => void
|
||||
setMessages: (conversationId: string, messages: Message[]) => void
|
||||
setUsers: (users: User[]) => void
|
||||
setSelectedConversation: (conversationId: string | null) => void
|
||||
setSearchQuery: (query: string) => void
|
||||
addMessage: (conversationId: string, message: Message) => void
|
||||
markAsRead: (conversationId: string) => void
|
||||
togglePin: (conversationId: string) => void
|
||||
toggleMute: (conversationId: string) => void
|
||||
setTyping: (conversationId: string, isTyping: boolean) => void
|
||||
setOnlineUsers: (userIds: string[]) => void
|
||||
}
|
||||
|
||||
export const useChat = create<ChatState & ChatActions>((set, get) => ({
|
||||
// State
|
||||
conversations: [],
|
||||
messages: {},
|
||||
users: [],
|
||||
selectedConversation: null,
|
||||
searchQuery: "",
|
||||
isTyping: {},
|
||||
onlineUsers: [],
|
||||
|
||||
// Actions
|
||||
setConversations: (conversations) => set({ conversations }),
|
||||
|
||||
setMessages: (conversationId, messages) =>
|
||||
set((state) => ({
|
||||
messages: { ...state.messages, [conversationId]: messages }
|
||||
})),
|
||||
|
||||
setUsers: (users) => set({ users }),
|
||||
|
||||
setSelectedConversation: (conversationId) => {
|
||||
set({ selectedConversation: conversationId })
|
||||
if (conversationId) {
|
||||
get().markAsRead(conversationId)
|
||||
}
|
||||
},
|
||||
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
|
||||
addMessage: (conversationId, message) =>
|
||||
set((state) => ({
|
||||
messages: {
|
||||
...state.messages,
|
||||
[conversationId]: [...(state.messages[conversationId] || []), message]
|
||||
},
|
||||
conversations: state.conversations.map((conv) =>
|
||||
conv.id === conversationId
|
||||
? {
|
||||
...conv,
|
||||
lastMessage: {
|
||||
id: message.id,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
senderId: message.senderId
|
||||
}
|
||||
}
|
||||
: conv
|
||||
)
|
||||
})),
|
||||
|
||||
markAsRead: (conversationId) =>
|
||||
set((state) => ({
|
||||
conversations: state.conversations.map((conv) =>
|
||||
conv.id === conversationId ? { ...conv, unreadCount: 0 } : conv
|
||||
)
|
||||
})),
|
||||
|
||||
togglePin: (conversationId) =>
|
||||
set((state) => ({
|
||||
conversations: state.conversations.map((conv) =>
|
||||
conv.id === conversationId ? { ...conv, isPinned: !conv.isPinned } : conv
|
||||
)
|
||||
})),
|
||||
|
||||
toggleMute: (conversationId) =>
|
||||
set((state) => ({
|
||||
conversations: state.conversations.map((conv) =>
|
||||
conv.id === conversationId ? { ...conv, isMuted: !conv.isMuted } : conv
|
||||
)
|
||||
})),
|
||||
|
||||
setTyping: (conversationId, isTyping) =>
|
||||
set((state) => ({
|
||||
isTyping: { ...state.isTyping, [conversationId]: isTyping }
|
||||
})),
|
||||
|
||||
setOnlineUsers: (userIds) => set({ onlineUsers: userIds }),
|
||||
}))
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { PlanLimitDialog } from "@/components/billing/plan-limit-dialog";
|
||||
import {
|
||||
createCustomerAction,
|
||||
updateCustomerAction,
|
||||
} from "@/lib/appwrite/customer-actions";
|
||||
import { initialCustomerState } from "@/lib/appwrite/customer-types";
|
||||
import type { CustomerRow } from "./types";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
customer?: CustomerRow | null;
|
||||
};
|
||||
|
||||
export function CustomerFormSheet({ open, onOpenChange, customer }: Props) {
|
||||
const isEdit = Boolean(customer);
|
||||
const action = isEdit ? updateCustomerAction : createCustomerAction;
|
||||
const [state, formAction, isPending] = useActionState(action, initialCustomerState);
|
||||
const [planLimitOpen, setPlanLimitOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success(isEdit ? "Müşteri güncellendi." : "Müşteri eklendi.");
|
||||
onOpenChange(false);
|
||||
} else if (state.code === "PLAN_LIMIT_EXCEEDED") {
|
||||
setPlanLimitOpen(true);
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
|
||||
<SheetHeader className="border-b px-6 py-4">
|
||||
<SheetTitle>{isEdit ? "Müşteriyi düzenle" : "Yeni müşteri"}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{isEdit
|
||||
? "Müşteri bilgilerini güncelleyin."
|
||||
: "Yeni bir müşteri ekleyin. * işaretli alanlar zorunludur."}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<form action={formAction} className="flex flex-1 flex-col">
|
||||
{isEdit && customer && <input type="hidden" name="id" value={customer.id} />}
|
||||
|
||||
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Ad / Şirket adı *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
defaultValue={customer?.name ?? ""}
|
||||
placeholder="Örn. Acme Yazılım Ltd."
|
||||
required
|
||||
/>
|
||||
{state.fieldErrors?.name && (
|
||||
<p className="text-destructive text-xs">{state.fieldErrors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
defaultValue={customer?.email ?? ""}
|
||||
placeholder="info@acme.com"
|
||||
/>
|
||||
{state.fieldErrors?.email && (
|
||||
<p className="text-destructive text-xs">{state.fieldErrors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="phone">Telefon</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
defaultValue={customer?.phone ?? ""}
|
||||
placeholder="+90 555 123 45 67"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="taxId">Vergi numarası</Label>
|
||||
<Input
|
||||
id="taxId"
|
||||
name="taxId"
|
||||
defaultValue={customer?.taxId ?? ""}
|
||||
placeholder="1234567890"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="status">Durum</Label>
|
||||
<Select name="status" defaultValue={customer?.status ?? "active"}>
|
||||
<SelectTrigger id="status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Aktif</SelectItem>
|
||||
<SelectItem value="passive">Pasif</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="address">Adres</Label>
|
||||
<Textarea
|
||||
id="address"
|
||||
name="address"
|
||||
rows={2}
|
||||
defaultValue={customer?.address ?? ""}
|
||||
placeholder="Açık adres"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="notes">Notlar</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
rows={4}
|
||||
defaultValue={customer?.notes ?? ""}
|
||||
placeholder="Müşteriye özel notlar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Vazgeç
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Kaydediliyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="size-4" />
|
||||
{isEdit ? "Güncelle" : "Kaydet"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
<PlanLimitDialog
|
||||
open={planLimitOpen}
|
||||
onOpenChange={setPlanLimitOpen}
|
||||
message={state.error}
|
||||
/>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
import { CustomerFormSheet } from "./customer-form-sheet";
|
||||
import { DeleteCustomerDialog } from "./delete-customer-dialog";
|
||||
import type { CustomerRow } from "./types";
|
||||
|
||||
type Props = { customers: CustomerRow[] };
|
||||
|
||||
export function CustomersClient({ customers }: Props) {
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<CustomerRow | null>(null);
|
||||
const [deleting, setDeleting] = useState<CustomerRow | null>(null);
|
||||
|
||||
const columns = useMemo<ColumnDef<CustomerRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
İsim
|
||||
<ArrowUpDown className="ml-2 size-3.5" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: "Email",
|
||||
cell: ({ row }) =>
|
||||
row.original.email ? (
|
||||
<a
|
||||
href={`mailto:${row.original.email}`}
|
||||
className="text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
{row.original.email}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "phone",
|
||||
header: "Telefon",
|
||||
cell: ({ row }) =>
|
||||
row.original.phone ? (
|
||||
<a
|
||||
href={`tel:${row.original.phone}`}
|
||||
className="text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
{row.original.phone}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Durum",
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.original.status === "active" ? "default" : "secondary"}>
|
||||
{row.original.status === "active" ? "Aktif" : "Pasif"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Eklendi
|
||||
<ArrowUpDown className="ml-2 size-3.5" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{new Date(row.original.createdAt).toLocaleDateString("tr-TR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-8">
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setEditing(row.original);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
Düzenle
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setDeleting(row.original)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Sil
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: customers,
|
||||
columns,
|
||||
state: { globalFilter, sorting },
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
initialState: { pagination: { pageSize: 20 } },
|
||||
globalFilterFn: (row, _id, filterValue) => {
|
||||
const v = String(filterValue).toLowerCase();
|
||||
return [row.original.name, row.original.email, row.original.phone, row.original.taxId]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(v);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="relative md:max-w-xs md:flex-1">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
placeholder="İsim, email, telefon, vergi no..."
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Yeni müşteri
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-32 text-center">
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2">
|
||||
<UserPlus className="size-6" />
|
||||
<p className="text-sm">Henüz müşteri eklenmemiş.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
İlk müşteriyi ekle
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex items-center justify-between border-t px-4 py-3">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Toplam {table.getFilteredRowModel().rows.length} müşteri
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Sayfa {table.getState().pagination.pageIndex + 1} /{" "}
|
||||
{Math.max(table.getPageCount(), 1)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CustomerFormSheet
|
||||
open={formOpen}
|
||||
onOpenChange={(v) => {
|
||||
setFormOpen(v);
|
||||
if (!v) setEditing(null);
|
||||
}}
|
||||
customer={editing}
|
||||
/>
|
||||
|
||||
<DeleteCustomerDialog
|
||||
open={Boolean(deleting)}
|
||||
onOpenChange={(v) => !v && setDeleting(null)}
|
||||
id={deleting?.id ?? null}
|
||||
name={deleting?.name ?? ""}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { deleteCustomerAction } from "@/lib/appwrite/customer-actions";
|
||||
|
||||
export function DeleteCustomerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
id,
|
||||
name,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
id: string | null;
|
||||
name: string;
|
||||
}) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!id) return;
|
||||
startTransition(async () => {
|
||||
const fd = new FormData();
|
||||
fd.set("id", id);
|
||||
const result = await deleteCustomerAction(fd);
|
||||
if (result.ok) {
|
||||
toast.success("Müşteri silindi.");
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error(result.error ?? "Silme başarısız.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Müşteriyi sil</DialogTitle>
|
||||
<DialogDescription>
|
||||
<strong>{name}</strong> kalıcı olarak silinecek. Bu işlem geri alınamaz.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||||
Vazgeç
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Siliniyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="size-4" />
|
||||
Sil
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export type CustomerRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
taxId: string;
|
||||
address: string;
|
||||
notes: string;
|
||||
status: "active" | "passive";
|
||||
createdAt: string;
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { UsageBanner } from "@/components/billing/usage-banner";
|
||||
import { listCustomers } from "@/lib/appwrite/customer-queries";
|
||||
import { getPlanUsage } from "@/lib/appwrite/plan-limits";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { CustomersClient } from "./components/customers-client";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "İşletmem — Müşteriler",
|
||||
};
|
||||
|
||||
export default async function CustomersPage() {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
redirect("/onboarding");
|
||||
}
|
||||
|
||||
const [customers, usage] = await Promise.all([
|
||||
listCustomers(ctx.tenantId),
|
||||
getPlanUsage(ctx),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-6 px-6 pt-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Müşteriler</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Müşterilerinizi yönetin, hizmet ve yazılım ilişkilerini buradan kurun.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UsageBanner usage={usage} resource="customers" />
|
||||
|
||||
<CustomersClient
|
||||
customers={customers.map((c) => ({
|
||||
id: c.$id,
|
||||
name: c.name,
|
||||
email: c.email ?? "",
|
||||
phone: c.phone ?? "",
|
||||
taxId: c.taxId ?? "",
|
||||
address: c.address ?? "",
|
||||
notes: c.notes ?? "",
|
||||
status: (c.status ?? "active") as "active" | "passive",
|
||||
createdAt: c.$createdAt,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
|
||||
import { ThemeCustomizer, ThemeCustomizerTrigger } from "@/components/theme-customizer";
|
||||
import { useSidebarConfig } from "@/hooks/use-sidebar-config";
|
||||
|
||||
export type ShellUser = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type ShellCompany = {
|
||||
id: string;
|
||||
name: string;
|
||||
logoUrl?: string | null;
|
||||
};
|
||||
|
||||
export function DashboardShell({
|
||||
user,
|
||||
company,
|
||||
children,
|
||||
}: {
|
||||
user: ShellUser;
|
||||
company: ShellCompany;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [themeCustomizerOpen, setThemeCustomizerOpen] = React.useState(false);
|
||||
const { config } = useSidebarConfig();
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": "16rem",
|
||||
"--sidebar-width-icon": "3rem",
|
||||
"--header-height": "calc(var(--spacing) * 14)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={config.collapsible === "none" ? "sidebar-none-mode" : ""}
|
||||
>
|
||||
{config.side === "left" ? (
|
||||
<>
|
||||
<AppSidebar
|
||||
user={user}
|
||||
company={company}
|
||||
variant={config.variant}
|
||||
collapsible={config.collapsible}
|
||||
side={config.side}
|
||||
/>
|
||||
<SidebarInset>
|
||||
<SiteHeader company={company} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<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 />
|
||||
</SidebarInset>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SidebarInset>
|
||||
<SiteHeader company={company} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<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 />
|
||||
</SidebarInset>
|
||||
<AppSidebar
|
||||
user={user}
|
||||
company={company}
|
||||
variant={config.variant}
|
||||
collapsible={config.collapsible}
|
||||
side={config.side}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ThemeCustomizerTrigger onClick={() => setThemeCustomizerOpen(true)} />
|
||||
<ThemeCustomizer
|
||||
open={themeCustomizerOpen}
|
||||
onOpenChange={setThemeCustomizerOpen}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
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"
|
||||
|
||||
const chartData = [
|
||||
{ date: "2024-04-01", desktop: 222, mobile: 150 },
|
||||
{ date: "2024-04-02", desktop: 97, mobile: 180 },
|
||||
{ date: "2024-04-03", desktop: 167, mobile: 120 },
|
||||
{ date: "2024-04-04", desktop: 242, mobile: 260 },
|
||||
{ date: "2024-04-05", desktop: 373, mobile: 290 },
|
||||
{ date: "2024-04-06", desktop: 301, mobile: 340 },
|
||||
{ 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 = {
|
||||
visitors: {
|
||||
label: "Visitors",
|
||||
},
|
||||
desktop: {
|
||||
label: "Desktop",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
mobile: {
|
||||
label: "Mobile",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
export function ChartAreaInteractive() {
|
||||
const isMobile = useIsMobile()
|
||||
const [timeRange, setTimeRange] = React.useState("90d")
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isMobile) {
|
||||
setTimeRange("7d")
|
||||
}
|
||||
}, [isMobile])
|
||||
|
||||
const filteredData = chartData.filter((item) => {
|
||||
const date = new Date(item.date)
|
||||
const referenceDate = new Date("2024-06-30")
|
||||
let daysToSubtract = 90
|
||||
if (timeRange === "30d") {
|
||||
daysToSubtract = 30
|
||||
} else if (timeRange === "7d") {
|
||||
daysToSubtract = 7
|
||||
}
|
||||
const startDate = new Date(referenceDate)
|
||||
startDate.setDate(startDate.getDate() - daysToSubtract)
|
||||
return date >= startDate
|
||||
})
|
||||
|
||||
return (
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardTitle>Total Visitors</CardTitle>
|
||||
<CardDescription>
|
||||
<span className="hidden @[540px]/card:block">
|
||||
Total for the last 3 months
|
||||
</span>
|
||||
<span className="@[540px]/card:hidden">Last 3 months</span>
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={timeRange}
|
||||
onValueChange={setTimeRange}
|
||||
variant="outline"
|
||||
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
|
||||
>
|
||||
<ToggleGroupItem value="90d">Last 3 months</ToggleGroupItem>
|
||||
<ToggleGroupItem value="30d">Last 30 days</ToggleGroupItem>
|
||||
<ToggleGroupItem value="7d">Last 7 days</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger
|
||||
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
|
||||
size="sm"
|
||||
aria-label="Select a value"
|
||||
>
|
||||
<SelectValue placeholder="Last 3 months" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="90d" className="rounded-lg">
|
||||
Last 3 months
|
||||
</SelectItem>
|
||||
<SelectItem value="30d" className="rounded-lg">
|
||||
Last 30 days
|
||||
</SelectItem>
|
||||
<SelectItem value="7d" className="rounded-lg">
|
||||
Last 7 days
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[250px] w-full"
|
||||
>
|
||||
<AreaChart data={filteredData}>
|
||||
<defs>
|
||||
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-desktop)"
|
||||
stopOpacity={1.0}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-desktop)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-mobile)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-mobile)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value)
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(value) => {
|
||||
return new Date(value as string | number | Date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}}
|
||||
indicator="dot"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
dataKey="mobile"
|
||||
type="natural"
|
||||
fill="url(#fillMobile)"
|
||||
stroke="var(--color-mobile)"
|
||||
stackId="a"
|
||||
/>
|
||||
<Area
|
||||
dataKey="desktop"
|
||||
type="natural"
|
||||
fill="url(#fillDesktop)"
|
||||
stroke="var(--color-desktop)"
|
||||
stackId="a"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
type Point = { month: string; count: number };
|
||||
|
||||
export function CustomerGrowth({ data }: { data: Point[] }) {
|
||||
const total = data.reduce((s, p) => s + p.count, 0);
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Yeni müşteriler</CardTitle>
|
||||
<CardDescription>Son 6 ay — toplam {total} yeni müşteri</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[220px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
fontSize={11}
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
fontSize={11}
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "hsl(var(--muted))" }}
|
||||
contentStyle={{
|
||||
background: "hsl(var(--popover))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={(value: unknown) => [`${value} müşteri`, "Yeni"]}
|
||||
/>
|
||||
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { formatTRY } from "@/lib/format";
|
||||
|
||||
type Point = { month: string; income: number; expense: number };
|
||||
|
||||
export function IncomeChart({ data }: { data: Point[] }) {
|
||||
const total = data.reduce((s, p) => s + p.income, 0);
|
||||
|
||||
return (
|
||||
<Card className="@container">
|
||||
<CardHeader>
|
||||
<CardTitle>Gelir / Gider</CardTitle>
|
||||
<CardDescription>
|
||||
Son 12 ay — toplam gelir {formatTRY(total)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[280px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="incomeGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="expenseGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
fontSize={11}
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
fontSize={11}
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
tickFormatter={(v) =>
|
||||
v >= 1000 ? `${(v / 1000).toFixed(0)}k` : String(v)
|
||||
}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: "hsl(var(--popover))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={(value: number, name: string) => [
|
||||
formatTRY(value),
|
||||
name === "income" ? "Gelir" : "Gider",
|
||||
]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="income"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
fill="url(#incomeGradient)"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="expense"
|
||||
stroke="#ef4444"
|
||||
strokeWidth={2}
|
||||
fill="url(#expenseGradient)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowDownRight,
|
||||
ArrowUpRight,
|
||||
CheckSquare,
|
||||
Receipt,
|
||||
Users,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { formatTRY } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import type { DashboardData } from "@/lib/appwrite/dashboard-queries";
|
||||
|
||||
function delta(current: number, previous: number): { pct: number; positive: boolean } | null {
|
||||
if (previous === 0) {
|
||||
if (current === 0) return null;
|
||||
return { pct: 100, positive: true };
|
||||
}
|
||||
const pct = ((current - previous) / previous) * 100;
|
||||
return { pct: Math.abs(pct), positive: pct >= 0 };
|
||||
}
|
||||
|
||||
export function Metrics({ data }: { data: DashboardData["metrics"] }) {
|
||||
const incomeDelta = delta(data.monthIncome, data.prevMonthIncome);
|
||||
|
||||
const cards = [
|
||||
{
|
||||
label: "Müşteriler",
|
||||
value: String(data.totalCustomers),
|
||||
sub: `${data.activeCustomers} aktif`,
|
||||
icon: Users,
|
||||
tone: "default",
|
||||
},
|
||||
{
|
||||
label: "Bu ayki gelir",
|
||||
value: formatTRY(data.monthIncome),
|
||||
sub: incomeDelta
|
||||
? `${incomeDelta.positive ? "+" : "−"}${incomeDelta.pct.toFixed(1)}% önceki ay`
|
||||
: "Geçen ay veri yok",
|
||||
icon: Wallet,
|
||||
tone: "income",
|
||||
trend: incomeDelta,
|
||||
},
|
||||
{
|
||||
label: "Bekleyen tahsilat",
|
||||
value: formatTRY(data.outstanding),
|
||||
sub:
|
||||
data.overdueCount > 0
|
||||
? `${data.overdueCount} vadesi geçmiş`
|
||||
: "Vadesi geçmiş yok",
|
||||
icon: Receipt,
|
||||
tone: data.overdueCount > 0 ? "warning" : "default",
|
||||
},
|
||||
{
|
||||
label: "Açık görevlerim",
|
||||
value: String(data.openTasks),
|
||||
sub:
|
||||
data.urgentTasks > 0
|
||||
? `${data.urgentTasks} acil`
|
||||
: data.openTasks === 0
|
||||
? "Hepsi tamam"
|
||||
: "Atanmış + atanmamış",
|
||||
icon: CheckSquare,
|
||||
tone: data.urgentTasks > 0 ? "warning" : "default",
|
||||
},
|
||||
];
|
||||
|
||||
const toneClass: Record<string, string> = {
|
||||
default: "text-muted-foreground",
|
||||
income: "text-emerald-600 dark:text-emerald-400",
|
||||
warning: "text-amber-600 dark:text-amber-400",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 @5xl:grid-cols-4">
|
||||
{cards.map((c) => {
|
||||
const Icon = c.icon;
|
||||
return (
|
||||
<Card key={c.label}>
|
||||
<CardContent className="flex items-start justify-between p-5">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{c.label}
|
||||
</p>
|
||||
<p className="mt-2 text-2xl font-semibold tabular-nums">{c.value}</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 flex items-center gap-1 text-xs",
|
||||
c.tone === "warning" && data.overdueCount + data.urgentTasks > 0
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{c.trend &&
|
||||
(c.trend.positive ? (
|
||||
<ArrowUpRight className="text-emerald-600 dark:text-emerald-400 size-3" />
|
||||
) : (
|
||||
<ArrowDownRight className="text-red-600 dark:text-red-400 size-3" />
|
||||
))}
|
||||
{c.tone === "warning" && data.overdueCount + data.urgentTasks > 0 && (
|
||||
<AlertCircle className="size-3" />
|
||||
)}
|
||||
{c.sub}
|
||||
</p>
|
||||
</div>
|
||||
<Icon className={cn("size-5", toneClass[c.tone])} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Link from "next/link";
|
||||
import { Calendar, FilePlus, Receipt, UserPlus } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function QuickActions() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/customers">
|
||||
<UserPlus className="size-3.5" />
|
||||
Müşteri
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/invoices">
|
||||
<Receipt className="size-3.5" />
|
||||
Fatura
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/calendar">
|
||||
<Calendar className="size-3.5" />
|
||||
Etkinlik
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/tasks">
|
||||
<FilePlus className="size-3.5" />
|
||||
Görev
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, Receipt } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { formatDate, formatTRY } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import type { DashboardData } from "@/lib/appwrite/dashboard-queries";
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
income: "Gelir",
|
||||
expense: "Gider",
|
||||
debt: "Borç",
|
||||
receivable: "Alacak",
|
||||
};
|
||||
|
||||
const TYPE_COLOR: Record<string, string> = {
|
||||
income: "text-emerald-600 dark:text-emerald-400",
|
||||
expense: "text-red-600 dark:text-red-400",
|
||||
debt: "text-amber-600 dark:text-amber-400",
|
||||
receivable: "text-blue-600 dark:text-blue-400",
|
||||
};
|
||||
|
||||
export function RecentTransactions({
|
||||
data,
|
||||
}: {
|
||||
data: DashboardData["recentTransactions"];
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Son işlemler</CardTitle>
|
||||
<CardDescription>En son finans hareketleri</CardDescription>
|
||||
</div>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/finance">
|
||||
Tümü <ArrowRight className="size-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.length === 0 ? (
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2 py-10 text-sm">
|
||||
<Receipt className="size-6" />
|
||||
<p>Henüz finans hareketi yok.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{data.map((t) => {
|
||||
const sign =
|
||||
t.type === "income" || t.type === "receivable" ? "+" : "−";
|
||||
return (
|
||||
<li key={t.id} className="flex items-center justify-between py-2.5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{TYPE_LABEL[t.type]}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatDate(t.date)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-sm">
|
||||
{t.customerName ? `${t.customerName} — ` : ""}
|
||||
{t.description || "—"}
|
||||
</p>
|
||||
</div>
|
||||
<span className={cn("font-medium tabular-nums", TYPE_COLOR[t.type])}>
|
||||
{sign} {formatTRY(t.amount)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { TrendingDown, TrendingUp } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
|
||||
export function SectionCards() {
|
||||
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">
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Total Revenue</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
$1,250.00
|
||||
</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">
|
||||
Trending up this month <TrendingUp className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Visitors for the last 6 months
|
||||
</div>
|
||||
</CardFooter>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Crown, TrendingUp } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { formatTRY } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Item = { name: string; total: number };
|
||||
|
||||
export function TopCustomers({ data }: { data: Item[] }) {
|
||||
const max = data[0]?.total ?? 1;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Crown className="size-4" />
|
||||
En çok ciro yapan müşteriler
|
||||
</CardTitle>
|
||||
<CardDescription>Ödenmiş faturaların toplam tutarına göre</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.length === 0 ? (
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2 py-10 text-sm">
|
||||
<TrendingUp className="size-6" />
|
||||
<p>Henüz ödenmiş fatura yok.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{data.map((c, i) => {
|
||||
const width = (c.total / max) * 100;
|
||||
return (
|
||||
<li key={c.name + i} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="truncate text-sm font-medium">
|
||||
<span className="text-muted-foreground mr-2 tabular-nums">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
{c.name}
|
||||
</span>
|
||||
<span className="text-sm tabular-nums">{formatTRY(c.total)}</span>
|
||||
</div>
|
||||
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
i === 0
|
||||
? "bg-emerald-500"
|
||||
: i === 1
|
||||
? "bg-emerald-400"
|
||||
: "bg-emerald-300",
|
||||
)}
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"header": "Cover page",
|
||||
"type": "Cover page",
|
||||
"status": "In Process",
|
||||
"target": "18",
|
||||
"limit": "5",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"header": "Table of contents",
|
||||
"type": "Table of contents",
|
||||
"status": "Done",
|
||||
"target": "29",
|
||||
"limit": "24",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"header": "Executive summary",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "10",
|
||||
"limit": "13",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"header": "Technical approach",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "27",
|
||||
"limit": "23",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"header": "Design",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "2",
|
||||
"limit": "16",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"header": "Capabilities",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "20",
|
||||
"limit": "8",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"header": "Integration with existing systems",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "21",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"header": "Innovation and Advantages",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "25",
|
||||
"limit": "26",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"header": "Overview of EMR's Innovative Solutions",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "7",
|
||||
"limit": "23",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"header": "Advanced Algorithms and Machine Learning",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "28",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"header": "Adaptive Communication Protocols",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "9",
|
||||
"limit": "31",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"header": "Advantages Over Current Technologies",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "12",
|
||||
"limit": "0",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"header": "Past Performance",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "33",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"header": "Customer Feedback and Satisfaction Levels",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "15",
|
||||
"limit": "34",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"header": "Implementation Challenges and Solutions",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "3",
|
||||
"limit": "35",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"header": "Security Measures and Data Protection Policies",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "6",
|
||||
"limit": "36",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"header": "Scalability and Future Proofing",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "4",
|
||||
"limit": "37",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"header": "Cost-Benefit Analysis",
|
||||
"type": "Plain language",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "38",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"header": "User Training and Onboarding Experience",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "17",
|
||||
"limit": "39",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"header": "Future Development Roadmap",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "11",
|
||||
"limit": "40",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"header": "System Architecture Overview",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "24",
|
||||
"limit": "18",
|
||||
"reviewer": "Maya Johnson"
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"header": "Risk Management Plan",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "15",
|
||||
"limit": "22",
|
||||
"reviewer": "Carlos Rodriguez"
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"header": "Compliance Documentation",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "31",
|
||||
"limit": "27",
|
||||
"reviewer": "Sarah Chen"
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"header": "API Documentation",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "8",
|
||||
"limit": "12",
|
||||
"reviewer": "Raj Patel"
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"header": "User Interface Mockups",
|
||||
"type": "Visual",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "25",
|
||||
"reviewer": "Leila Ahmadi"
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"header": "Database Schema",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "20",
|
||||
"reviewer": "Thomas Wilson"
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"header": "Testing Methodology",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "17",
|
||||
"limit": "14",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"header": "Deployment Strategy",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "30",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"header": "Budget Breakdown",
|
||||
"type": "Financial",
|
||||
"status": "In Process",
|
||||
"target": "13",
|
||||
"limit": "16",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"header": "Market Analysis",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "29",
|
||||
"limit": "32",
|
||||
"reviewer": "Sophia Martinez"
|
||||
},
|
||||
{
|
||||
"id": 31,
|
||||
"header": "Competitor Comparison",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "19",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"header": "Maintenance Plan",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "16",
|
||||
"limit": "23",
|
||||
"reviewer": "Alex Thompson"
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"header": "User Personas",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "24",
|
||||
"reviewer": "Nina Patel"
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"header": "Accessibility Compliance",
|
||||
"type": "Legal",
|
||||
"status": "Done",
|
||||
"target": "18",
|
||||
"limit": "21",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 35,
|
||||
"header": "Performance Metrics",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "23",
|
||||
"limit": "26",
|
||||
"reviewer": "David Kim"
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"header": "Disaster Recovery Plan",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "17",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 37,
|
||||
"header": "Third-party Integrations",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "25",
|
||||
"limit": "28",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"header": "User Feedback Summary",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "20",
|
||||
"limit": "15",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"header": "Localization Strategy",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "12",
|
||||
"limit": "19",
|
||||
"reviewer": "Maria Garcia"
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"header": "Mobile Compatibility",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "28",
|
||||
"limit": "31",
|
||||
"reviewer": "James Wilson"
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"header": "Data Migration Plan",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "22",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"header": "Quality Assurance Protocols",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "33",
|
||||
"reviewer": "Priya Singh"
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"header": "Stakeholder Analysis",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "11",
|
||||
"limit": "14",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 44,
|
||||
"header": "Environmental Impact Assessment",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "24",
|
||||
"limit": "27",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 45,
|
||||
"header": "Intellectual Property Rights",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "17",
|
||||
"limit": "20",
|
||||
"reviewer": "Sarah Johnson"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"header": "Customer Support Framework",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "25",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 47,
|
||||
"header": "Version Control Strategy",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "15",
|
||||
"limit": "18",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 48,
|
||||
"header": "Continuous Integration Pipeline",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "29",
|
||||
"reviewer": "Michael Chen"
|
||||
},
|
||||
{
|
||||
"id": 49,
|
||||
"header": "Regulatory Compliance",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "13",
|
||||
"limit": "16",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 50,
|
||||
"header": "User Authentication System",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "28",
|
||||
"limit": "31",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 51,
|
||||
"header": "Data Analytics Framework",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "24",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 52,
|
||||
"header": "Cloud Infrastructure",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "16",
|
||||
"limit": "19",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 53,
|
||||
"header": "Network Security Measures",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "29",
|
||||
"limit": "32",
|
||||
"reviewer": "Lisa Wong"
|
||||
},
|
||||
{
|
||||
"id": 54,
|
||||
"header": "Project Timeline",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "17",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 55,
|
||||
"header": "Resource Allocation",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "30",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 56,
|
||||
"header": "Team Structure and Roles",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "20",
|
||||
"limit": "23",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 57,
|
||||
"header": "Communication Protocols",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "15",
|
||||
"limit": "18",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"header": "Success Metrics",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "33",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 59,
|
||||
"header": "Internationalization Support",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "23",
|
||||
"limit": "26",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 60,
|
||||
"header": "Backup and Recovery Procedures",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "18",
|
||||
"limit": "21",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 61,
|
||||
"header": "Monitoring and Alerting System",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "25",
|
||||
"limit": "28",
|
||||
"reviewer": "Daniel Park"
|
||||
},
|
||||
{
|
||||
"id": 62,
|
||||
"header": "Code Review Guidelines",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "12",
|
||||
"limit": "15",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 63,
|
||||
"header": "Documentation Standards",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "30",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 64,
|
||||
"header": "Release Management Process",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "25",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 65,
|
||||
"header": "Feature Prioritization Matrix",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "22",
|
||||
"reviewer": "Emma Davis"
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"header": "Technical Debt Assessment",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "24",
|
||||
"limit": "27",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 67,
|
||||
"header": "Capacity Planning",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "24",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 68,
|
||||
"header": "Service Level Agreements",
|
||||
"type": "Legal",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "29",
|
||||
"reviewer": "Assign reviewer"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"header": "Technical Specifications Document v2.1",
|
||||
"type": "Technical Document",
|
||||
"status": "Final",
|
||||
"target": "100%",
|
||||
"limit": "100%",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"header": "Security Compliance Report Q4 2024",
|
||||
"type": "Compliance Document",
|
||||
"status": "Under Review",
|
||||
"target": "95%",
|
||||
"limit": "100%",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"header": "Project Management Plan v3.0",
|
||||
"type": "Management Document",
|
||||
"status": "Final",
|
||||
"target": "100%",
|
||||
"limit": "100%",
|
||||
"reviewer": "Emily Whalen"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"header": "Risk Assessment Matrix 2025",
|
||||
"type": "Risk Document",
|
||||
"status": "Draft",
|
||||
"target": "80%",
|
||||
"limit": "90%",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"header": "Quality Assurance Protocol v1.5",
|
||||
"type": "QA Document",
|
||||
"status": "Final",
|
||||
"target": "100%",
|
||||
"limit": "100%",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"header": "Dr. Sarah Mitchell",
|
||||
"type": "Project Manager",
|
||||
"status": "Active",
|
||||
"target": "15 years",
|
||||
"limit": "20 years",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"header": "James Thompson",
|
||||
"type": "Lead Engineer",
|
||||
"status": "Active",
|
||||
"target": "12 years",
|
||||
"limit": "15 years",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"header": "Maria Rodriguez",
|
||||
"type": "Security Specialist",
|
||||
"status": "Active",
|
||||
"target": "8 years",
|
||||
"limit": "10 years",
|
||||
"reviewer": "Emily Whalen"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"header": "David Chen",
|
||||
"type": "Systems Architect",
|
||||
"status": "Active",
|
||||
"target": "10 years",
|
||||
"limit": "12 years",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"header": "Lisa Johnson",
|
||||
"type": "Quality Assurance Lead",
|
||||
"status": "Active",
|
||||
"target": "6 years",
|
||||
"limit": "8 years",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"header": "Federal Communications Commission - Network Infrastructure Modernization",
|
||||
"type": "Government Contract",
|
||||
"status": "Completed",
|
||||
"target": "95%",
|
||||
"limit": "100%",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"header": "Department of Defense - Cybersecurity Enhancement Program",
|
||||
"type": "Defense Contract",
|
||||
"status": "Completed",
|
||||
"target": "98%",
|
||||
"limit": "100%",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"header": "NASA - Satellite Communication System Upgrade",
|
||||
"type": "Space Technology",
|
||||
"status": "Completed",
|
||||
"target": "92%",
|
||||
"limit": "95%",
|
||||
"reviewer": "Emily Whalen"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"header": "Department of Homeland Security - Border Security Tech",
|
||||
"type": "Security Contract",
|
||||
"status": "In Progress",
|
||||
"target": "85%",
|
||||
"limit": "90%",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"header": "GSA - Cloud Infrastructure Migration",
|
||||
"type": "IT Services",
|
||||
"status": "Completed",
|
||||
"target": "96%",
|
||||
"limit": "98%",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getActiveContext } from "@/lib/appwrite/active-context";
|
||||
import { getDashboardData } from "@/lib/appwrite/dashboard-queries";
|
||||
|
||||
import { CustomerGrowth } from "./components/customer-growth";
|
||||
import { IncomeChart } from "./components/income-chart";
|
||||
import { Metrics } from "./components/metrics";
|
||||
import { QuickActions } from "./components/quick-actions";
|
||||
import { RecentTransactions } from "./components/recent-transactions";
|
||||
import { TopCustomers } from "./components/top-customers";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const ctx = await getActiveContext();
|
||||
if (!ctx) redirect("/onboarding");
|
||||
|
||||
const data = await getDashboardData(ctx.tenantId, ctx.user.id);
|
||||
|
||||
const firstName = ctx.user.name?.split(" ")[0] ?? "";
|
||||
const companyName = ctx.settings?.companyName ?? "Çalışma alanı";
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-6 px-6 pt-0">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center md:gap-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-sm">{companyName}</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{firstName ? `Hoş geldiniz, ${firstName}` : "Genel bakış"}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
İşletmenizin temel metriklerini ve son hareketleri buradan takip edin.
|
||||
</p>
|
||||
</div>
|
||||
<QuickActions />
|
||||
</div>
|
||||
|
||||
<div className="@container/main space-y-6">
|
||||
<Metrics data={data.metrics} />
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 @5xl:grid-cols-2">
|
||||
<IncomeChart data={data.monthlyIncome} />
|
||||
<TopCustomers data={data.topCustomers} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 @5xl:grid-cols-2">
|
||||
<RecentTransactions data={data.recentTransactions} />
|
||||
<CustomerGrowth data={data.newCustomersMonthly} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const schema = z.object({
|
||||
id: z.number(),
|
||||
header: z.string(),
|
||||
type: z.string(),
|
||||
status: z.string(),
|
||||
target: z.string(),
|
||||
limit: z.string(),
|
||||
reviewer: z.string(),
|
||||
})
|
||||
|
||||
export type Task = z.infer<typeof schema>
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Search } from "lucide-react"
|
||||
|
||||
interface FAQ {
|
||||
id: number
|
||||
question: string
|
||||
answer: string
|
||||
category: string
|
||||
}
|
||||
|
||||
interface Category {
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface FAQListProps {
|
||||
faqs: FAQ[]
|
||||
categories: Category[]
|
||||
}
|
||||
|
||||
export function FAQList({ faqs, categories }: FAQListProps) {
|
||||
const [selectedCategory, setSelectedCategory] = useState("All")
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
// Filter FAQs based on selected category and search query
|
||||
const filteredFaqs = faqs.filter(faq => {
|
||||
const matchesCategory = selectedCategory === "All" || faq.category === selectedCategory
|
||||
const matchesSearch = searchQuery === "" ||
|
||||
faq.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
faq.answer.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
return matchesCategory && matchesSearch
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-6 xl:grid-cols-4 gap-6">
|
||||
{/* Categories Sidebar */}
|
||||
<Card className="lg:col-span-2 xl:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Categories</CardTitle>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search FAQs..."
|
||||
className="pl-10 cursor-pointer"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{categories.map((category) => (
|
||||
<div
|
||||
key={category.name}
|
||||
className={cn(
|
||||
"flex items-center justify-between px-3 py-2 rounded-lg hover:bg-muted cursor-pointer transition-colors group",
|
||||
selectedCategory === category.name && "bg-muted"
|
||||
)}
|
||||
onClick={() => setSelectedCategory(category.name)}
|
||||
>
|
||||
<span className="font-medium">{category.name}</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
selectedCategory === category.name && "bg-background"
|
||||
)}
|
||||
>
|
||||
{category.name === "All" ? faqs.length : category.count}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* FAQs List */}
|
||||
<div className="lg:col-span-4 xl:col-span-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
{selectedCategory === "All" ? "All FAQs" : `${selectedCategory} FAQs`}
|
||||
<span className="text-sm font-normal text-muted-foreground ml-2">
|
||||
({filteredFaqs.length} {filteredFaqs.length === 1 ? 'question' : 'questions'})
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[570px] pr-4">
|
||||
{filteredFaqs.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>No FAQs found matching your search criteria.</p>
|
||||
</div>
|
||||
) : (
|
||||
<Accordion type='single' className='space-y-4' defaultValue="item-1">
|
||||
{filteredFaqs.map((item) => (
|
||||
<AccordionItem
|
||||
key={item.id}
|
||||
value={`item-${item.id}`}
|
||||
className='rounded-md !border'
|
||||
>
|
||||
<AccordionTrigger className='cursor-pointer px-4 hover:no-underline'>
|
||||
<div className="flex items-start text-left">
|
||||
<span>{item.question}</span>
|
||||
<Badge variant="outline" className="ms-3 mt-0.5 shrink-0 text-xs">
|
||||
{item.category}
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className='text-muted-foreground px-4'>
|
||||
{item.answer}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowRight, Sparkles, Shield, Truck, Clock } from 'lucide-react'
|
||||
|
||||
interface FeatureItem {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
interface FeaturesGridProps {
|
||||
features: FeatureItem[]
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
Sparkles,
|
||||
Shield,
|
||||
Truck,
|
||||
Clock,
|
||||
}
|
||||
|
||||
export function FeaturesGrid({ features }: FeaturesGridProps) {
|
||||
return (
|
||||
<div className='grid gap-4 sm:grid-cols-2 sm:gap-6 xl:grid-cols-4 mt-8'>
|
||||
{features.map(feature => {
|
||||
const IconComponent = iconMap[feature.icon as keyof typeof iconMap]
|
||||
return (
|
||||
<article key={feature.id} className='group'>
|
||||
<Card className='relative h-full overflow-hidden transition-all hover:shadow-md'>
|
||||
<CardContent className='px-6'>
|
||||
<Badge variant='secondary' className='mb-4 inline-flex size-12 items-center justify-center'>
|
||||
<IconComponent className='!size-5' aria-hidden='true' />
|
||||
</Badge>
|
||||
<h3 className='mb-2 text-lg font-semibold'>{feature.title}</h3>
|
||||
<p className='text-muted-foreground mb-4 text-sm'>{feature.description}</p>
|
||||
|
||||
<Button
|
||||
variant='link'
|
||||
size='sm'
|
||||
className='text-muted-foreground hover:text-foreground h-auto cursor-pointer !p-0 text-sm'
|
||||
>
|
||||
Learn more
|
||||
<ArrowRight className='ms-1.5 size-4' />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{ "name": "All", "count": 46 },
|
||||
{ "name": "General", "count": 8 },
|
||||
{ "name": "Account", "count": 6 },
|
||||
{ "name": "Billing", "count": 8 },
|
||||
{ "name": "Technical", "count": 9 },
|
||||
{ "name": "Privacy", "count": 5 },
|
||||
{ "name": "Security", "count": 4 },
|
||||
{ "name": "Support", "count": 6 }
|
||||
]
|
||||
@@ -0,0 +1,278 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"question": "What is ShadcnStore Admin?",
|
||||
"answer": "ShadcnStore Admin is a comprehensive admin dashboard template built with React, TypeScript, and shadcn/ui components. It provides a complete solution for managing your e-commerce store or business operations.",
|
||||
"category": "General"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"question": "How do I get started?",
|
||||
"answer": "You can get started by signing up for an account, choosing a plan that fits your needs, and following our quick setup guide to configure your dashboard.",
|
||||
"category": "General"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"question": "Do you offer a free trial?",
|
||||
"answer": "Yes, we offer a 14-day free trial for all new users. No credit card is required to start the trial, and you can explore all features during this period.",
|
||||
"category": "General"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"question": "What browsers are supported?",
|
||||
"answer": "We support all modern browsers including Chrome, Firefox, Safari, and Edge. For the best experience, we recommend using the latest version of your preferred browser.",
|
||||
"category": "General"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"question": "How do I contact support?",
|
||||
"answer": "You can contact our support team through the support page, by email at support@shadcnstore.com, or through the live chat feature available 24/7.",
|
||||
"category": "General"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"question": "Is there a mobile app available?",
|
||||
"answer": "Currently, we offer a responsive web application that works great on mobile devices. A dedicated mobile app is planned for future release.",
|
||||
"category": "General"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"question": "Can I customize the dashboard?",
|
||||
"answer": "Yes, the dashboard is highly customizable. You can modify themes, layouts, add custom components, and configure various settings to match your brand.",
|
||||
"category": "General"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"question": "What integrations are available?",
|
||||
"answer": "We offer integrations with popular services like Stripe, PayPal, Shopify, WooCommerce, Google Analytics, and many more through our API.",
|
||||
"category": "General"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"question": "How do I reset my password?",
|
||||
"answer": "You can reset your password by clicking on the 'Forgot Password' link on the login page. Enter your email address, and we'll send you instructions to reset your password.",
|
||||
"category": "Account"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"question": "How do I change my email address?",
|
||||
"answer": "You can change your email address in your account settings under the 'User Settings' section. You'll need to verify the new email address before the change takes effect.",
|
||||
"category": "Account"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"question": "Can I have multiple team members?",
|
||||
"answer": "Yes, depending on your plan, you can invite team members and assign different roles and permissions to manage your store collaboratively.",
|
||||
"category": "Account"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"question": "How do I delete my account?",
|
||||
"answer": "To delete your account, go to your account settings and select 'Delete Account'. Please note that this action is irreversible and all data will be permanently removed.",
|
||||
"category": "Account"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"question": "Can I change my username?",
|
||||
"answer": "Yes, you can change your username in the account settings. Keep in mind that some features might reference your old username temporarily.",
|
||||
"category": "Account"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"question": "How do I enable two-factor authentication?",
|
||||
"answer": "You can enable two-factor authentication in your account security settings. We support both SMS and authenticator app methods for added security.",
|
||||
"category": "Account"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"question": "What payment methods do you accept?",
|
||||
"answer": "We accept all major credit cards (Visa, MasterCard, American Express), PayPal, and bank transfers for enterprise customers. All payments are processed securely.",
|
||||
"category": "Billing"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"question": "How can I upgrade my plan?",
|
||||
"answer": "You can upgrade your plan at any time from your account settings. Go to 'Plans & Billing' and select the plan that best fits your needs. Changes take effect immediately.",
|
||||
"category": "Billing"
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"question": "Can I downgrade my plan?",
|
||||
"answer": "Yes, you can downgrade your plan at any time. The change will take effect at the start of your next billing cycle to ensure you don't lose access to premium features.",
|
||||
"category": "Billing"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"question": "Do you offer refunds?",
|
||||
"answer": "We offer a 30-day money-back guarantee for all plans. If you're not satisfied, contact our support team for a full refund within 30 days of purchase.",
|
||||
"category": "Billing"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"question": "How does billing work?",
|
||||
"answer": "Billing is processed monthly or annually depending on your chosen plan. You'll receive an invoice before each billing cycle, and payment is automatically charged to your selected method.",
|
||||
"category": "Billing"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"question": "Can I change my billing cycle?",
|
||||
"answer": "Yes, you can switch between monthly and annual billing at any time. Annual billing offers significant savings compared to monthly billing.",
|
||||
"category": "Billing"
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"question": "What happens if payment fails?",
|
||||
"answer": "If a payment fails, we'll attempt to charge your card again after 3 days. You'll receive email notifications, and your account will remain active during this grace period.",
|
||||
"category": "Billing"
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"question": "How do I view my billing history?",
|
||||
"answer": "You can view your complete billing history in the 'Plans & Billing' section of your account settings. All invoices and receipts are available for download.",
|
||||
"category": "Billing"
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"question": "Can I export my data?",
|
||||
"answer": "Yes, you can export your data at any time from your account settings. We provide exports in multiple formats including CSV, JSON, and PDF for different data types.",
|
||||
"category": "Technical"
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"question": "What APIs do you provide?",
|
||||
"answer": "We provide comprehensive REST APIs for all major features including product management, order processing, customer data, and analytics. Full documentation is available.",
|
||||
"category": "Technical"
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"question": "How do I backup my data?",
|
||||
"answer": "We automatically backup all your data daily. You can also create manual backups anytime from your settings, and restore from any backup point within the last 30 days.",
|
||||
"category": "Technical"
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"question": "Is there a rate limit on API calls?",
|
||||
"answer": "Yes, API rate limits vary by plan. Basic plans have 1000 calls/hour, Professional plans have 10,000 calls/hour, and Enterprise plans have unlimited calls.",
|
||||
"category": "Technical"
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"question": "How do I set up webhooks?",
|
||||
"answer": "Webhooks can be configured in the 'Connections' section of your settings. You can set up webhooks for various events like new orders, payment confirmations, and inventory updates.",
|
||||
"category": "Technical"
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"question": "What about system maintenance?",
|
||||
"answer": "We perform maintenance during low-traffic hours (typically Sunday 2-4 AM UTC). You'll be notified at least 48 hours in advance of any scheduled maintenance.",
|
||||
"category": "Technical"
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"question": "How do I troubleshoot connection issues?",
|
||||
"answer": "First, check your internet connection and try refreshing the page. If issues persist, check our status page or contact support with specific error messages.",
|
||||
"category": "Technical"
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"question": "Can I use custom domains?",
|
||||
"answer": "Yes, Professional and Enterprise plans support custom domains. You can configure your custom domain in the 'Connections' section of your account settings.",
|
||||
"category": "Technical"
|
||||
},
|
||||
{
|
||||
"id": 31,
|
||||
"question": "What databases do you support?",
|
||||
"answer": "We support integration with MySQL, PostgreSQL, MongoDB, and other popular databases through our Database Sync feature available in higher-tier plans.",
|
||||
"category": "Technical"
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"question": "How do you handle my personal data?",
|
||||
"answer": "We follow strict data protection policies and comply with GDPR, CCPA, and other privacy regulations. Your personal data is never shared with third parties without your consent.",
|
||||
"category": "Privacy"
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"question": "Can I request my data?",
|
||||
"answer": "Yes, you can request a complete copy of your personal data at any time. We'll provide it in a machine-readable format within 30 days of your request.",
|
||||
"category": "Privacy"
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"question": "How long do you retain data?",
|
||||
"answer": "We retain your data as long as your account is active. After account deletion, personal data is removed within 30 days, though some anonymized analytics may be retained.",
|
||||
"category": "Privacy"
|
||||
},
|
||||
{
|
||||
"id": 35,
|
||||
"question": "Do you use cookies?",
|
||||
"answer": "Yes, we use essential cookies for functionality and optional cookies for analytics and personalization. You can manage your cookie preferences in your account settings.",
|
||||
"category": "Privacy"
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"question": "Is my data encrypted?",
|
||||
"answer": "Yes, all data is encrypted both in transit (using TLS 1.3) and at rest (using AES-256 encryption). We use industry-standard security practices to protect your information.",
|
||||
"category": "Privacy"
|
||||
},
|
||||
{
|
||||
"id": 37,
|
||||
"question": "How secure is my data?",
|
||||
"answer": "We implement bank-level security with end-to-end encryption, regular security audits, and compliance with SOC 2 Type II standards. Your data security is our top priority.",
|
||||
"category": "Security"
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"question": "Do you support SSO?",
|
||||
"answer": "Yes, Enterprise plans include Single Sign-On (SSO) support with popular providers like Google, Microsoft Azure AD, and Okta for seamless team access.",
|
||||
"category": "Security"
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"question": "What about password requirements?",
|
||||
"answer": "We require strong passwords with at least 8 characters, including uppercase, lowercase, numbers, and special characters. We also highly recommend enabling two-factor authentication.",
|
||||
"category": "Security"
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"question": "How do you handle security incidents?",
|
||||
"answer": "We have a comprehensive incident response plan. In case of any security issues, we immediately investigate, contain the issue, and notify affected users within 24 hours.",
|
||||
"category": "Security"
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"question": "What support channels are available?",
|
||||
"answer": "We offer email support, live chat, and phone support (for Enterprise customers). Our knowledge base and community forums are also available 24/7.",
|
||||
"category": "Support"
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"question": "What are your support hours?",
|
||||
"answer": "Email and chat support are available 24/7. Phone support for Enterprise customers is available Monday-Friday, 9 AM-6 PM in your local timezone.",
|
||||
"category": "Support"
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"question": "How quickly will I get a response?",
|
||||
"answer": "Response times vary by plan: Basic (24 hours), Professional (12 hours), Enterprise (2 hours). Critical issues are prioritized and responded to immediately.",
|
||||
"category": "Support"
|
||||
},
|
||||
{
|
||||
"id": 44,
|
||||
"question": "Do you offer training?",
|
||||
"answer": "Yes, we provide comprehensive onboarding for all plans, video tutorials, documentation, and personalized training sessions for Enterprise customers.",
|
||||
"category": "Support"
|
||||
},
|
||||
{
|
||||
"id": 45,
|
||||
"question": "Can you help with custom implementations?",
|
||||
"answer": "Enterprise customers get access to our professional services team for custom implementations, integrations, and consulting services.",
|
||||
"category": "Support"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"question": "Is there a community forum?",
|
||||
"answer": "Yes, we have an active community forum where users share tips, ask questions, and get help from both our team and other community members.",
|
||||
"category": "Support"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Premium Quality",
|
||||
"description": "Handcrafted with premium materials and meticulous attention to detail.",
|
||||
"icon": "Sparkles"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Secure Shopping",
|
||||
"description": "100% secure payment processing with end-to-end encryption.",
|
||||
"icon": "Shield"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Fast Delivery",
|
||||
"description": "Free worldwide shipping and hassle-free returns within 30 days.",
|
||||
"icon": "Truck"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "24/7 Support",
|
||||
"description": "Round-the-clock customer support to assist you anytime.",
|
||||
"icon": "Clock"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
import { FAQList } from "./components/faq-list"
|
||||
import { FeaturesGrid } from "./components/features-grid"
|
||||
|
||||
// Import data
|
||||
import categoriesData from "./data/categories.json"
|
||||
import faqsData from "./data/faqs.json"
|
||||
import featuresData from "./data/features.json"
|
||||
|
||||
export default function FAQsPage() {
|
||||
return (
|
||||
<div className="px-4 lg:px-6">
|
||||
<FAQList faqs={faqsData} categories={categoriesData} />
|
||||
<FeaturesGrid features={featuresData} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
createBankAccountAction,
|
||||
updateBankAccountAction,
|
||||
} from "@/lib/appwrite/bank-account-actions";
|
||||
import { initialBankAccountState } from "@/lib/appwrite/bank-account-types";
|
||||
import { ScopeToggle } from "@/components/finance/scope-toggle";
|
||||
|
||||
import type { BankAccountRow } from "./types";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
account?: BankAccountRow | null;
|
||||
};
|
||||
|
||||
export function BankFormSheet({ open, onOpenChange, account }: Props) {
|
||||
const isEdit = Boolean(account);
|
||||
const action = isEdit ? updateBankAccountAction : createBankAccountAction;
|
||||
const [state, formAction, isPending] = useActionState(action, initialBankAccountState);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success(isEdit ? "Hesap güncellendi." : "Hesap eklendi.");
|
||||
onOpenChange(false);
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
|
||||
<SheetHeader className="border-b px-6 py-4">
|
||||
<SheetTitle>{isEdit ? "Hesabı düzenle" : "Yeni banka hesabı"}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Açılış bakiyesi sonradan değiştirilirse bütün hareketler aynı kalır, sadece toplam
|
||||
kayar.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<form action={formAction} className="flex flex-1 flex-col">
|
||||
{isEdit && account && <input type="hidden" name="id" value={account.id} />}
|
||||
|
||||
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
|
||||
<ScopeToggle defaultValue={(account as { scope?: "company" | "personal" } | null)?.scope ?? "company"} />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="bankName">Banka *</Label>
|
||||
<Input
|
||||
id="bankName"
|
||||
name="bankName"
|
||||
defaultValue={account?.bankName ?? ""}
|
||||
placeholder="Örn. Garanti BBVA"
|
||||
required
|
||||
/>
|
||||
{state.fieldErrors?.bankName && (
|
||||
<p className="text-destructive text-xs">{state.fieldErrors.bankName}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="accountName">Hesap adı *</Label>
|
||||
<Input
|
||||
id="accountName"
|
||||
name="accountName"
|
||||
defaultValue={account?.accountName ?? ""}
|
||||
placeholder="Örn. Şirket TL Vadesiz"
|
||||
required
|
||||
/>
|
||||
{state.fieldErrors?.accountName && (
|
||||
<p className="text-destructive text-xs">{state.fieldErrors.accountName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="iban">IBAN</Label>
|
||||
<Input
|
||||
id="iban"
|
||||
name="iban"
|
||||
defaultValue={account?.iban ?? ""}
|
||||
placeholder="TR.. .... .... .... .... .... .."
|
||||
style={{ fontFamily: "monospace", textTransform: "uppercase" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="openingBalance">Açılış bakiyesi (₺)</Label>
|
||||
<Input
|
||||
id="openingBalance"
|
||||
name="openingBalance"
|
||||
type="number"
|
||||
step="0.01"
|
||||
defaultValue={account?.openingBalance ?? 0}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Bu hesabı sisteme eklediğinizdeki bakiye. Sonraki hareketler bu rakamın üstüne eklenir.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="notes">Notlar</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
rows={3}
|
||||
defaultValue={account?.notes ?? ""}
|
||||
placeholder="Şube, yetkili, müşteri no, vb."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Vazgeç
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Kaydediliyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="size-4" />
|
||||
{isEdit ? "Güncelle" : "Kaydet"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
Building2,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
archiveBankAccountAction,
|
||||
deleteBankAccountAction,
|
||||
} from "@/lib/appwrite/bank-account-actions";
|
||||
import { formatTRY } from "@/lib/format";
|
||||
import { ScopeBadge } from "@/components/finance/scope-toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { BankFormSheet } from "./bank-form-sheet";
|
||||
import type { BankAccountRow } from "./types";
|
||||
|
||||
type Props = { accounts: BankAccountRow[] };
|
||||
|
||||
export function BanksClient({ accounts }: Props) {
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<BankAccountRow | null>(null);
|
||||
const [deleting, setDeleting] = useState<BankAccountRow | null>(null);
|
||||
const [busy, startTransition] = useTransition();
|
||||
|
||||
const active = accounts.filter((a) => !a.archived);
|
||||
const archived = accounts.filter((a) => a.archived);
|
||||
const totalBalance = active.reduce((s, a) => s + a.balance, 0);
|
||||
|
||||
const toggleArchive = (acc: BankAccountRow) => {
|
||||
startTransition(async () => {
|
||||
const fd = new FormData();
|
||||
fd.set("id", acc.id);
|
||||
const result = await archiveBankAccountAction(fd);
|
||||
if (result.ok) {
|
||||
toast.success(acc.archived ? "Hesap geri açıldı." : "Hesap arşivlendi.");
|
||||
} else {
|
||||
toast.error(result.error ?? "İşlem başarısız.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleting) return;
|
||||
startTransition(async () => {
|
||||
const fd = new FormData();
|
||||
fd.set("id", deleting.id);
|
||||
const result = await deleteBankAccountAction(fd);
|
||||
if (result.ok) {
|
||||
toast.success("Hesap silindi.");
|
||||
setDeleting(null);
|
||||
} else {
|
||||
toast.error(result.error ?? "Silme başarısız.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Card className="flex-1">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs">Toplam bakiye (aktif hesaplar)</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 text-2xl font-semibold tabular-nums",
|
||||
totalBalance >= 0 ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400",
|
||||
)}
|
||||
>
|
||||
{formatTRY(totalBalance)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Yeni hesap
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{active.length === 0 && archived.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-2 py-12 text-center">
|
||||
<Building2 className="text-muted-foreground size-8" />
|
||||
<p className="text-sm">Henüz banka hesabı eklenmemiş.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
İlk hesabı ekle
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{active.map((a) => (
|
||||
<AccountCard
|
||||
key={a.id}
|
||||
account={a}
|
||||
onEdit={() => {
|
||||
setEditing(a);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
onArchiveToggle={() => toggleArchive(a)}
|
||||
onDelete={() => setDeleting(a)}
|
||||
busy={busy}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{archived.length > 0 && (
|
||||
<details className="group">
|
||||
<summary className="text-muted-foreground hover:text-foreground cursor-pointer text-sm">
|
||||
Arşivlenmiş hesaplar ({archived.length})
|
||||
</summary>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{archived.map((a) => (
|
||||
<AccountCard
|
||||
key={a.id}
|
||||
account={a}
|
||||
onEdit={() => {
|
||||
setEditing(a);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
onArchiveToggle={() => toggleArchive(a)}
|
||||
onDelete={() => setDeleting(a)}
|
||||
busy={busy}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<BankFormSheet
|
||||
open={formOpen}
|
||||
onOpenChange={(v) => {
|
||||
setFormOpen(v);
|
||||
if (!v) setEditing(null);
|
||||
}}
|
||||
account={editing}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(deleting)} onOpenChange={(v) => !v && setDeleting(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Hesabı sil</DialogTitle>
|
||||
<DialogDescription>
|
||||
<strong>{deleting?.bankName} — {deleting?.accountName}</strong> kalıcı olarak silinecek.
|
||||
Bağlı finans hareketi varsa silme reddedilir; o durumda arşivlemeyi tercih edin.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleting(null)} disabled={busy}>
|
||||
Vazgeç
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={busy}>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
|
||||
Sil
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountCard({
|
||||
account,
|
||||
onEdit,
|
||||
onArchiveToggle,
|
||||
onDelete,
|
||||
busy,
|
||||
}: {
|
||||
account: BankAccountRow;
|
||||
onEdit: () => void;
|
||||
onArchiveToggle: () => void;
|
||||
onDelete: () => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const positive = account.balance >= 0;
|
||||
return (
|
||||
<Card className={cn(account.archived && "opacity-60")}>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="text-muted-foreground size-4 shrink-0" />
|
||||
<h3 className="truncate font-medium">{account.bankName}</h3>
|
||||
{account.archived && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
Arşivli
|
||||
</Badge>
|
||||
)}
|
||||
<ScopeBadge scope={account.scope} />
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-0.5 truncate text-sm">{account.accountName}</p>
|
||||
{account.iban && (
|
||||
<p className="text-muted-foreground mt-1 truncate font-mono text-[11px]">
|
||||
{account.iban.replace(/(.{4})/g, "$1 ").trim()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-8 shrink-0" disabled={busy}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Pencil className="size-3.5" />
|
||||
Düzenle
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onArchiveToggle}>
|
||||
{account.archived ? (
|
||||
<>
|
||||
<ArchiveRestore className="size-3.5" />
|
||||
Arşivden çıkar
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive className="size-3.5" />
|
||||
Arşivle
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={onDelete}>
|
||||
<Trash2 className="size-3.5" />
|
||||
Sil
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">Güncel bakiye</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-xl font-semibold tabular-nums",
|
||||
positive ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400",
|
||||
)}
|
||||
>
|
||||
{formatTRY(account.balance)}
|
||||
</p>
|
||||
{account.balance !== account.openingBalance && (
|
||||
<p className="text-muted-foreground mt-0.5 text-[11px]">
|
||||
Açılış: {formatTRY(account.openingBalance)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export type BankAccountRow = {
|
||||
id: string;
|
||||
bankName: string;
|
||||
accountName: string;
|
||||
iban: string;
|
||||
openingBalance: number;
|
||||
notes: string;
|
||||
archived: boolean;
|
||||
balance: number;
|
||||
scope: "company" | "personal";
|
||||
};
|
||||