diff --git a/.artifacts/next-dev.err.log b/.artifacts/next-dev.err.log deleted file mode 100644 index e69de29..0000000 diff --git a/.artifacts/next-dev.out.log b/.artifacts/next-dev.out.log deleted file mode 100644 index cdd03f2..0000000 --- a/.artifacts/next-dev.out.log +++ /dev/null @@ -1,19 +0,0 @@ - -> mood-tracker-mvp@0.1.0 dev D:\Poyraz\kodlama\Introduction-to-Data-Visualization-Project-Assignment -> next dev "--port" "3010" - -▲ Next.js 16.2.6 (Turbopack) -- Local: http://localhost:3010 -- Network: http://192.168.0.114:3010 -- Environments: .env.local -✓ Ready in 1070ms -Creating turbopack project { - dir: 'D:\\Poyraz\\kodlama\\Introduction-to-Data-Visualization-Project-Assignment', - testMode: true -} - -○ Compiling /login ... - GET /login 200 in 8.7s (next.js: 7.6s, proxy.ts: 335ms, application-code: 738ms) - GET /register 200 in 8.4s (next.js: 8.0s, proxy.ts: 10ms, application-code: 395ms) -[?25h - ELIFECYCLE  Command failed with exit code 1. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e8536f1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +.next +.data +backups +.git +.env* +npm-debug.log* +yarn-debug.log* +yarn-error.log* +Dockerfile +docker-compose.yml diff --git a/.env.example b/.env.example index b6153f4..aed0096 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,29 @@ -# Public URL where users open Neta. +# Public URL where users open Neta. Production'da localhost dışında HTTPS kullanın. NEXT_PUBLIC_SITE_URL=http://localhost:3000 -# Supabase project API URL, for example: -# https://your-project-ref.supabase.co -NEXT_PUBLIC_SUPABASE_URL= +# Canonical server-side app URL used by auth callbacks and trusted origin checks. +# Defaults to NEXT_PUBLIC_SITE_URL when empty. +APP_URL= -# Supabase anon/public key. -NEXT_PUBLIC_SUPABASE_ANON_KEY= +# Optional Better Auth base URL override. Defaults to APP_URL/NEXT_PUBLIC_SITE_URL. +BETTER_AUTH_URL= -# Supabase service role key. Required for creating the first admin, -# creating client portal users, and server-side storage uploads. -# Keep this secret. Never expose it with a NEXT_PUBLIC_ prefix. -SUPABASE_SERVICE_ROLE_KEY= +# Required at production runtime. Generate with: openssl rand -base64 32 +BETTER_AUTH_SECRET= + +# Optional comma-separated extra trusted origins. Wildcards are rejected. +TRUSTED_ORIGINS= + +# Persistent application data directory. In Docker this should be /app/data. +DATA_DIR=.data + +# Optional explicit SQLite database path. Defaults to DATA_DIR/neta.db. +DATABASE_PATH= + +# Optional local OpenAI-compatible Ollama endpoint and AI request timeout. +OLLAMA_BASE_URL=http://127.0.0.1:11434/v1 + +AI_REQUEST_TIMEOUT_MS=30000 + +# Optional SemVer floor advertised to future iOS/Android clients. Empty disables enforcement. +NETA_MINIMUM_MOBILE_VERSION= diff --git a/.gitignore b/.gitignore index b6f7fde..edd8165 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ out dist build backups/ +.data/ .env* !.env.example !.env.full.example @@ -11,3 +12,9 @@ npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* +.pnpm-store/ +*.tsbuildinfo +.DS_Store +.artifacts/ +__pycache__/ +*.py[cod] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..59380df --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +FROM node:22-bookworm-slim AS deps +WORKDIR /app +RUN corepack enable +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile --config.node-linker=hoisted + +FROM node:22-bookworm-slim AS builder +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +RUN corepack enable +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN pnpm --config.node-linker=hoisted build + +FROM node:22-bookworm-slim AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV HOSTNAME=0.0.0.0 +ENV PORT=3000 +ENV DATA_DIR=/app/data + +RUN groupadd --system --gid 1001 nodejs \ + && useradd --system --uid 1001 --gid nodejs nextjs \ + && mkdir -p /app/data \ + && chown -R nextjs:nodejs /app/data + +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/scripts ./scripts +COPY --from=builder /app/server/db/migrations ./server/db/migrations +COPY --from=builder /app/node_modules/drizzle-orm ./node_modules/drizzle-orm +COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3 +COPY --from=builder /app/node_modules/bindings ./node_modules/bindings +COPY --from=builder /app/node_modules/file-uri-to-path ./node_modules/file-uri-to-path + +USER nextjs +EXPOSE 3000 +VOLUME ["/app/data"] +CMD ["sh", "-c", "node scripts/migrate.mjs && node server.js"] diff --git a/README.md b/README.md index 80fb786..08acbdb 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,197 @@ -Neta Icon +Neta # Neta -Neta is a freelancer operating system built with Next.js and Supabase. It manages clients, projects, tasks, finance, daily logs, analytics, AI chat, and a limited client portal. +Neta; freelancer'ların müşteri, proje, görev, takvim, finans, günlük, AI ve sınırlı müşteri portalı akışlarını kendi sunucularında yönetebildiği bir Next.js uygulamasıdır. -This repository now ships only the web application. It does not bundle Supabase, PostgreSQL, Docker Compose, installers, migration runners, or backup scripts. Bring your own Supabase project and provide the required environment variables in the deploy platform. +Self-hosted v3 runtime'ı harici bir BaaS istemez: -## Live Demo +- Next.js App Router ve React +- Better Auth +- SQLite (`better-sqlite3`) ve Drizzle ORM +- Yerel persistent dosya alanı +- Poyraz UI v3 +- İsteğe bağlı Google, OpenAI, Groq veya Ollama AI sağlayıcısı -You can try the demo here: +Supabase yalnızca eski bir Neta kurulumundan veri aktarmak için opsiyonel kaynak olabilir. Uygulamanın build veya runtime aşamasında Supabase projesi, paketi ya da environment değişkeni gerekmez. -```txt -https://demo.takeneta.com +## Çalışma modeli + +Bir Neta instance'ı tek freelancer/owner ve birden fazla davetli müşteri hesabı için tasarlanmıştır. Uygulama tek bir uzun ömürlü Node.js process'i ve tek bir persistent data volume ile çalışır; aynı SQLite dosyasına yazan yatay ölçekli birden fazla replica desteklenmez. + +Kalıcı veri ağacı: + +```text +/app/data/ + neta.db + uploads/ + backups/ + tmp/ ``` -Demo account: +## Gereksinimler -```txt -Email: test@takeneta.com -Password: 123456 -``` +- Node.js 22 +- pnpm 11.5.1 (lokal geliştirme ve Docker build için; sürüm `packageManager` alanında sabittir) +- Production'da kalıcı disk/volume +- Localhost dışındaki production kurulumunda HTTPS reverse proxy -## Stack - -- Next.js App Router -- React -- Tailwind CSS -- Poyraz UI -- Supabase Auth, Postgres, Storage, and RLS -- Vercel AI SDK - -## Requirements - -1. A Supabase project that already contains Neta's database schema, RLS policies, RPC functions, and storage buckets. -2. Supabase project credentials: - - Project URL - - Anon/public key - - Service role key -3. Node.js 20 or newer. - -See `docs/04-supabase-kurulumu.md` for the expected Supabase-side resources. - -For a fresh Supabase project, run the one-shot setup SQL: +## Lokal kurulum ```bash -psql "postgresql://postgres:[PASSWORD]@[HOST]:5432/postgres" -v ON_ERROR_STOP=1 -f supabase/setup.sql +pnpm install --frozen-lockfile +cp .env.example .env.local +openssl rand -base64 32 ``` -You can also paste the full contents of `supabase/setup.sql` into Supabase SQL Editor and run it once. +`pnpm-lock.yaml` repository'nin tek canonical dependency lockfile'ıdır. Lokal kurulum ve Docker image aynı çözümü kullanır. -## Environment Variables +Üretilen secret'ı `.env.local` içindeki `BETTER_AUTH_SECRET` alanına koyun, ardından: -Copy `.env.example` to `.env.local` for local development, or add the same values in Vercel, Coolify, Dokploy, or your hosting provider. +```bash +pnpm dev +``` + +`pnpm dev` ve `pnpm start`, Next.js başlamadan önce bekleyen SQLite migration'larını otomatik ve idempotent olarak uygular. Migration'ı uygulamadan bağımsız çalıştırmak için `pnpm db:migrate` kullanılabilir. + +`http://localhost:3000/register` adresinden ilk owner hesabını oluşturun. İlk başarılı kurulumdan sonra public kayıt atomik olarak kapanır. + +## Environment sözleşmesi + +Minimum production örneği: ```env -NEXT_PUBLIC_SITE_URL=https://your-domain.com -NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co -NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key -SUPABASE_SERVICE_ROLE_KEY=your-service-role-key +NODE_ENV=production +NEXT_PUBLIC_SITE_URL=https://neta.example.com +APP_URL=https://neta.example.com +BETTER_AUTH_SECRET=openssl-ile-uretilmis-en-az-32-karakter-secret +DATA_DIR=/app/data ``` -`SUPABASE_SERVICE_ROLE_KEY` is server-only. Do not expose it with a `NEXT_PUBLIC_` prefix. +Opsiyonel alanlar: -## Local Development +- `BETTER_AUTH_URL`: Auth callback base URL override'ı. +- `TRUSTED_ORIGINS`: Virgülle ayrılmış ek güvenilir origin listesi; wildcard reddedilir. +- `DATABASE_PATH`: Varsayılan `DATA_DIR/neta.db` yerine özel SQLite yolu. +- `OLLAMA_BASE_URL`: Varsayılan `http://127.0.0.1:11434/v1`. +- `AI_REQUEST_TIMEOUT_MS`: AI istek timeout'u; varsayılan `30000`. +- `NETA_MINIMUM_MOBILE_VERSION`: Mobil istemcilere ilan edilen opsiyonel SemVer alt sınırı. + +AI provider API key'leri environment'a yazılmaz; owner ayarından girilir, server-side şifreli saklanır ve browser'a geri dönmez. + +## Docker ile production ```bash -npm install -npm run dev +export BETTER_AUTH_SECRET="$(openssl rand -base64 32)" +export APP_URL="https://neta.example.com" +export NEXT_PUBLIC_SITE_URL="$APP_URL" +docker compose up -d --build ``` -Open `http://localhost:3000`. +Compose; `/app/data` için named volume bağlar, migration'ları uygulamadan önce çalıştırır, non-root user kullanır ve readiness healthcheck tanımlar. Domain/HTTPS sonlandırmasını Caddy, Traefik, Nginx, Coolify veya Dokploy üzerinden yapın. -## Production Build +Health endpoint'leri: + +- `/api/health/live`: Process liveness. +- `/api/health/ready`: SQLite, data directory ve migration readiness. +- `/api/health`: Hafif uyumluluk endpoint'i. + +Coolify ve Dokploy'da repository'nin `Dockerfile` dosyasını kullanın, internal portu `3000` seçin ve `/app/data` yoluna persistent volume bağlayın. Tek replica kullanın. Ayrıntılı production ve upgrade runbook'u: [Faz 8 import/release rehberi](docs/self-hosted-redesign/phase-8-import-release.md). + +## İlk owner ve müşteri daveti + +İlk açılışta `/register` üzerinden freelancer hesabı oluşturulur. Sonraki kullanıcılar public kayıt olamaz. + +Müşteri erişimi için: + +1. Owner müşteri kaydını oluşturur. +2. Müşteri detayından süreli, tek kullanımlık davet üretir. +3. Müşteri linki açıp kendi şifresini belirler. +4. Better Auth hesabı ilgili müşteri kaydına transaction içinde bağlanır. + +Davet token'ının yalnızca hash'i saklanır. Eski Supabase Auth şifre/session verileri import edilmez; taşınan müşteriler yeniden davet edilmelidir. + +## Marka özelleştirmesi + +`Ayarlar > Genel` alanındaki workspace adı, meta title, kısa uygulama adı, açık/koyu tema logoları, favicon, ana renk ve görünüm tercihi SQLite'ta tutulur ve root layout'a server-side uygulanır. Görseller yerel upload alanında saklanır. Branding mutation'ı yalnızca owner rolüne açıktır; portal aynı güvenli public marka çıktısını kullanır. `/api/v1/meta` bu markayı absolute asset URL'leriyle, `/api/v1/me` ise oturum sahibinin renk modu tercihiyle mobil istemcilere sunar. + +## Backup ve restore + +Online SQLite snapshot ve upload ağacı: ```bash -npm run build -npm run start +pnpm db:backup +pnpm db:backup -- --retention-count 14 ``` -## Deploy +`BACKUP_RETENTION_COUNT=14` aynı retention politikasını cron ortamından verebilir. Her backup; byte size ve SHA-256 içeren bir manifest üretir. -### Vercel +Restore sırasında uygulamayı durdurun: -1. Import the GitHub repository. -2. Add the environment variables from `.env.example`. -3. Deploy with the default Next.js settings. +```bash +pnpm db:restore -- --from /path/to/neta-backup --force +``` -### Coolify or Dokploy +Farklı bir data directory'ye prova: -1. Create a standard Next.js application from this GitHub repository. -2. Use the platform's normal install/build/start commands: - - Install: `npm install` - - Build: `npm run build` - - Start: `npm run start` -3. Add the environment variables from `.env.example`. +```bash +pnpm db:restore -- --from /path/to/neta-backup --target /tmp/neta-restore-test --force +``` -No Dockerfile or Compose file is required. +Restore önce manifest bütünlüğünü doğrular, dosyaları stage eder ve DB/upload ağacını aynı filesystem üzerinde atomik swap ile değiştirir. Hata olursa önceki hedef geri alınır. Backup'ları ayrıca host dışındaki şifreli bir konuma kopyalayın. -## First Admin +## Upgrade -After deploying against a prepared Supabase project, open `/register` once to create the first freelancer/admin account. Registration is locked after the first admin profile exists. +1. Mevcut sürümde backup alın ve geri yükleme provasını yapın. +2. Yeni image/tag'i indirin veya build edin. +3. Uygulamayı tek replica ile başlatın; container startup migration'ları deterministik uygular. +4. `/api/health/ready`, login, müşteri, proje ve portal akışlarını kontrol edin. +5. Sorunda eski image'i ve upgrade öncesi backup'ı kullanarak rollback yapın. -## License +SQLite şema downgrade'i desteklenmez; yalnızca eski application image'ine dönmek yeterli değildir. -This project is proprietary and intended for personal self-hosting with an external Supabase project. +## Eski Supabase verisini aktarma + +Önce bu instance'ta owner hesabını oluşturun, ardından export bundle üzerinde dry-run çalıştırın: + +```bash +pnpm db:import:supabase -- \ + --from /secure/path/neta-export \ + --owner-user-id BETTER_AUTH_OWNER_ID \ + --dry-run +``` + +Raporu doğruladıktan ve backup aldıktan sonra aynı komutu `--dry-run` olmadan çalıştırın. Bundle formatı, normalization kararları, dosya yapısı ve production cutover/rollback adımları [Faz 8 rehberinde](docs/self-hosted-redesign/phase-8-import-release.md) tanımlıdır. + +## Mobil istemci ve instance discovery + +React Native istemcileri bir Neta kurulumunu şu public endpoint'lerle tanıyabilir: + +```text +GET /.well-known/neta +GET /api/v1/meta +GET /api/v1/health +GET /api/v1/me +``` + +`/.well-known/neta` kalıcı instance kimliğini ve API URL'sini, `/api/v1/meta` marka/sürüm/capability sözleşmesini döndürür. `/api/v1/me` Better Auth session gerektirir ve token veya secret döndürmez. + +Device pairing henüz runtime'a açılmamıştır; capability `planned` durumundadır. Mobil bağlantı algoritması ve API version kuralları [Faz 9 rehberinde](docs/self-hosted-redesign/phase-9-mobile-api.md), gelecek pairing/token güvenliği [ADR-0018](docs/self-hosted-redesign/adr-0018-device-pairing.md) belgesinde tanımlıdır. + +## Kalite kontrolleri + +```bash +pnpm typecheck +pnpm phase8:release-boundary +pnpm phase8:import-smoke +pnpm phase9:smoke +pnpm build +``` + +`phase8:release-boundary`; Supabase, PWA ve browser database bağımlılıklarının runtime'a geri dönmesini engeller. + +Güncel teknik yayın durumu, doğrulama kanıtları, kalıntı güvenlik riskleri ve gerçek production cutover sınırı: [2026-07-18 release-readiness raporu](docs/self-hosted-redesign/release-readiness-2026-07-18.md). + +## Lisans + +Bu proje kişisel self-hosting amacıyla geliştirilen proprietary bir projedir. diff --git a/__pycache__/main.cpython-314.pyc b/__pycache__/main.cpython-314.pyc deleted file mode 100644 index 04e5fca..0000000 Binary files a/__pycache__/main.cpython-314.pyc and /dev/null differ diff --git a/app/(dashboard)/analytics/analytics-client.tsx b/app/(dashboard)/analytics/analytics-client.tsx index 8157d9a..6b29c0e 100644 --- a/app/(dashboard)/analytics/analytics-client.tsx +++ b/app/(dashboard)/analytics/analytics-client.tsx @@ -7,7 +7,6 @@ import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis, PieChart, Pie, Cell, Legend } from "recharts"; -import { BarChart3, Filter } from "lucide-react"; export type AnalyticsData = { metrics: { @@ -22,7 +21,7 @@ type AnalyticsClientProps = { data: AnalyticsData; }; -const COLORS = ["hsl(var(--primary))", "hsl(var(--destructive))", "#eab308", "#3b82f6", "#8b5cf6"]; +const COLORS = ["var(--poyraz-primary)", "var(--poyraz-destructive)", "#eab308", "#3b82f6", "#8b5cf6"]; export function AnalyticsClient({ data }: AnalyticsClientProps) { const router = useRouter(); @@ -45,19 +44,10 @@ export function AnalyticsClient({ data }: AnalyticsClientProps) { return (
-
-
- - Analizler -
-
-

- Performans ve Finans Analizi -

-

- Müşteri bazlı gelirler, görev tamamlama oranları ve proje ilerleme grafikleri. -

-
+
+

+ Performans ve Finans Analizi +

@@ -98,8 +88,8 @@ export function AnalyticsClient({ data }: AnalyticsClientProps) { `₺${Number(value ?? 0)}`} contentStyle={{ - backgroundColor: 'hsl(var(--background))', - borderColor: 'hsl(var(--border))', + backgroundColor: 'var(--poyraz-background)', + borderColor: 'var(--poyraz-border)', borderRadius: '0.375rem', }} /> @@ -119,9 +109,9 @@ export function AnalyticsClient({ data }: AnalyticsClientProps) {
- - - + + + { @@ -130,7 +120,7 @@ export function AnalyticsClient({ data }: AnalyticsClientProps) {

{label}

- {payload.map((entry: any, index: number) => ( + {payload.map((entry, index) => (
diff --git a/app/(dashboard)/analytics/loading.tsx b/app/(dashboard)/analytics/loading.tsx index fba49f2..3f2bf67 100644 --- a/app/(dashboard)/analytics/loading.tsx +++ b/app/(dashboard)/analytics/loading.tsx @@ -1,5 +1,4 @@ -import { Skeleton } from "@/components/ui/skeleton"; -import { Card, CardContent } from "poyraz-ui/atoms"; +import { Card, CardContent, Skeleton } from "poyraz-ui/atoms"; export default function AnalyticsLoading() { return ( diff --git a/app/(dashboard)/analytics/page.tsx b/app/(dashboard)/analytics/page.tsx index 44a15fc..26b63b6 100644 --- a/app/(dashboard)/analytics/page.tsx +++ b/app/(dashboard)/analytics/page.tsx @@ -1,59 +1,19 @@ -import { createClient } from "@/lib/supabase/server"; -import { AnalyticsClient } from "./analytics-client"; -import { redirect } from "next/navigation"; +import { AnalyticsClient, type AnalyticsData } from "./analytics-client"; +import { parseDashboardRange, resolveDashboardRange } from "@/server/services/analytics-range"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; -export const metadata = { - title: "Analizler - Neta", -}; +export const metadata = { title: "Analizler" }; export default async function AnalyticsPage({ searchParams, }: { - searchParams: { [key: string]: string | string[] | undefined }; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }) { - const supabase = await createClient(); - const { data: { user } } = await supabase.auth.getUser(); + const params = await searchParams; + const range = parseDashboardRange(params.range); + const { actor, service } = await requireFreelancerBackend(); + const metrics = service.getFreelancerAnalytics(actor, resolveDashboardRange(range)); + const data: AnalyticsData = { metrics, range }; - if (!user) { - redirect("/login"); - } - - const range = typeof searchParams.range === "string" ? searchParams.range : "this_month"; - - const now = new Date(); - let startDate = new Date(); - let endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59); - - if (range === "this_week") { - const tempNow = new Date(); - const firstDay = new Date(tempNow.setDate(tempNow.getDate() - tempNow.getDay() + (tempNow.getDay() === 0 ? -6 : 1))); - firstDay.setHours(0, 0, 0, 0); - startDate = firstDay; - endDate = new Date(firstDay.getTime()); - endDate.setDate(endDate.getDate() + 6); - endDate.setHours(23, 59, 59, 999); - } else if (range === "this_month") { - startDate = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0); - endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59); - } else if (range === "this_year") { - startDate = new Date(now.getFullYear(), 0, 1, 0, 0, 0); - endDate = new Date(now.getFullYear(), 11, 31, 23, 59, 59); - } - - // Fetch metrics using RPC - const { data: metricsData } = await supabase.rpc('get_analytics_metrics', { - p_start_date: startDate.toISOString(), - p_end_date: endDate.toISOString() - }); - - const analyticsData = { - metrics: metricsData || { - projectIncomeData: [], - completedTasks: 0, - activeTasks: 0 - }, - range - }; - - return ; + return ; } diff --git a/app/(dashboard)/business/invoices/invoices-client.tsx b/app/(dashboard)/business/invoices/invoices-client.tsx index 4b44e3d..d78f284 100644 --- a/app/(dashboard)/business/invoices/invoices-client.tsx +++ b/app/(dashboard)/business/invoices/invoices-client.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { format } from "date-fns"; import { tr } from "date-fns/locale"; -import { Receipt, Plus, MoreHorizontal, FileEdit, Trash2, Send, Download, CheckCircle2 } from "lucide-react"; +import { Plus, MoreHorizontal, FileEdit, Trash2, Send, Download, CheckCircle2 } from "lucide-react"; import { Button, Card, CardContent, Badge } from "poyraz-ui/atoms"; import { DropdownMenu, @@ -54,9 +54,8 @@ export function InvoicesClient({ invoices }: { invoices: InvoiceRow[] }) {

Faturalar

-

Müşteri faturalarınızı ve ödemeleri takip edin.

-
@@ -107,7 +106,7 @@ export function InvoicesClient({ invoices }: { invoices: InvoiceRow[] }) { - @@ -148,7 +147,7 @@ export function InvoicesClient({ invoices }: { invoices: InvoiceRow[] }) {

Yeni Fatura Ekle

Bu özellik şu an geliştirme aşamasındadır.

- +
diff --git a/app/(dashboard)/business/invoices/page.tsx b/app/(dashboard)/business/invoices/page.tsx index 6916fdb..f9b2463 100644 --- a/app/(dashboard)/business/invoices/page.tsx +++ b/app/(dashboard)/business/invoices/page.tsx @@ -1,42 +1,21 @@ -import { createClient } from "@/lib/supabase/server"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; import { InvoicesClient, type InvoiceRow } from "./invoices-client"; export default async function InvoicesPage() { - const supabase = await createClient(); - const { data: { user } } = await supabase.auth.getUser(); - - if (!user) { - return null; - } - - const { data: invoicesData } = await supabase - .from("invoices") - .select(` - id, - invoice_number, - amount, - currency, - status, - issue_date, - due_date, - created_at, - clients ( name ), - projects ( name ) - `) - .eq("user_id", user.id) - .order("created_at", { ascending: false }); - - const invoices: InvoiceRow[] = (invoicesData || []).map((i: any) => ({ - id: i.id, - invoice_number: i.invoice_number, - amount: Number(i.amount), - currency: i.currency, - status: i.status, - issue_date: i.issue_date, - due_date: i.due_date, - created_at: i.created_at, - clientName: i.clients?.name || null, - projectName: i.projects?.name || null, + const { actor, service } = await requireFreelancerBackend(); + const clientNames = new Map(service.listClients(actor).map((client) => [client.id, client.name])); + const projectNames = new Map(service.listProjects(actor).map((project) => [project.id, project.name])); + const invoices: InvoiceRow[] = service.listInvoices(actor).map((invoice) => ({ + id: invoice.id, + invoice_number: invoice.invoiceNumber, + amount: invoice.amountMinor / 100, + currency: invoice.currency, + status: invoice.status, + issue_date: invoice.issueDate, + due_date: invoice.dueDate, + created_at: invoice.createdAt.toISOString(), + clientName: invoice.clientId ? clientNames.get(invoice.clientId) ?? null : null, + projectName: invoice.projectId ? projectNames.get(invoice.projectId) ?? null : null, })); return ; diff --git a/app/(dashboard)/business/proposals/page.tsx b/app/(dashboard)/business/proposals/page.tsx index ad79318..eed877f 100644 --- a/app/(dashboard)/business/proposals/page.tsx +++ b/app/(dashboard)/business/proposals/page.tsx @@ -1,40 +1,20 @@ -import { createClient } from "@/lib/supabase/server"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; import { ProposalsClient, type ProposalRow } from "./proposals-client"; export default async function ProposalsPage() { - const supabase = await createClient(); - const { data: { user } } = await supabase.auth.getUser(); - - if (!user) { - return null; - } - - const { data: proposalsData } = await supabase - .from("proposals") - .select(` - id, - title, - amount, - currency, - status, - valid_until, - created_at, - clients ( name ), - projects ( name ) - `) - .eq("user_id", user.id) - .order("created_at", { ascending: false }); - - const proposals: ProposalRow[] = (proposalsData || []).map((p: any) => ({ - id: p.id, - title: p.title, - amount: Number(p.amount), - currency: p.currency, - status: p.status, - valid_until: p.valid_until, - created_at: p.created_at, - clientName: p.clients?.name || null, - projectName: p.projects?.name || null, + const { actor, service } = await requireFreelancerBackend(); + const clientNames = new Map(service.listClients(actor).map((client) => [client.id, client.name])); + const projectNames = new Map(service.listProjects(actor).map((project) => [project.id, project.name])); + const proposals: ProposalRow[] = service.listProposals(actor).map((proposal) => ({ + id: proposal.id, + title: proposal.title, + amount: proposal.amountMinor / 100, + currency: proposal.currency, + status: proposal.status, + valid_until: proposal.validUntil?.toISOString() ?? null, + created_at: proposal.createdAt.toISOString(), + clientName: proposal.clientId ? clientNames.get(proposal.clientId) ?? null : null, + projectName: proposal.projectId ? projectNames.get(proposal.projectId) ?? null : null, })); return ; diff --git a/app/(dashboard)/business/proposals/proposals-client.tsx b/app/(dashboard)/business/proposals/proposals-client.tsx index a6d0ac1..b620c7e 100644 --- a/app/(dashboard)/business/proposals/proposals-client.tsx +++ b/app/(dashboard)/business/proposals/proposals-client.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { format } from "date-fns"; import { tr } from "date-fns/locale"; -import { FileText, Plus, MoreHorizontal, FileEdit, Trash2, Mail, CheckCircle2, XCircle } from "lucide-react"; +import { Plus, MoreHorizontal, FileEdit, Trash2, Mail, CheckCircle2, XCircle } from "lucide-react"; import { Button, Card, CardContent, Badge } from "poyraz-ui/atoms"; import { DropdownMenu, @@ -52,9 +52,8 @@ export function ProposalsClient({ proposals }: { proposals: ProposalRow[] }) {

Teklifler

-

Müşterilerinize sunduğunuz teklifleri yönetin.

-
@@ -106,7 +105,7 @@ export function ProposalsClient({ proposals }: { proposals: ProposalRow[] }) { - @@ -148,7 +147,7 @@ export function ProposalsClient({ proposals }: { proposals: ProposalRow[] }) {

Yeni Teklif Ekle

Bu özellik şu an geliştirme aşamasındadır.

- +
diff --git a/app/(dashboard)/business/subscriptions/page.tsx b/app/(dashboard)/business/subscriptions/page.tsx index 7410f9a..1505115 100644 --- a/app/(dashboard)/business/subscriptions/page.tsx +++ b/app/(dashboard)/business/subscriptions/page.tsx @@ -1,40 +1,18 @@ -import { createClient } from "@/lib/supabase/server"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; import { SubscriptionsClient, type SubscriptionRow } from "./subscriptions-client"; export default async function SubscriptionsPage() { - const supabase = await createClient(); - const { data: { user } } = await supabase.auth.getUser(); - - if (!user) { - return null; - } - - const { data: subscriptionsData } = await supabase - .from("subscriptions") - .select(` - id, - name, - amount, - currency, - billing_cycle, - status, - category, - next_billing_date, - created_at - `) - .eq("user_id", user.id) - .order("created_at", { ascending: false }); - - const subscriptions: SubscriptionRow[] = (subscriptionsData || []).map((s: any) => ({ - id: s.id, - name: s.name, - amount: Number(s.amount), - currency: s.currency, - billing_cycle: s.billing_cycle, - status: s.status, - category: s.category, - next_billing_date: s.next_billing_date, - created_at: s.created_at, + const { actor, service } = await requireFreelancerBackend(); + const subscriptions: SubscriptionRow[] = service.listSubscriptions(actor).map((subscription) => ({ + id: subscription.id, + name: subscription.name, + amount: subscription.amountMinor / 100, + currency: subscription.currency, + billing_cycle: subscription.billingCycle, + status: subscription.status, + category: subscription.category, + next_billing_date: subscription.nextBillingDate, + created_at: subscription.createdAt.toISOString(), })); return ; diff --git a/app/(dashboard)/business/subscriptions/subscriptions-client.tsx b/app/(dashboard)/business/subscriptions/subscriptions-client.tsx index 94e326f..2753e90 100644 --- a/app/(dashboard)/business/subscriptions/subscriptions-client.tsx +++ b/app/(dashboard)/business/subscriptions/subscriptions-client.tsx @@ -58,9 +58,8 @@ export function SubscriptionsClient({ subscriptions }: { subscriptions: Subscrip

Abonelikler ve Masraflar

-

Sabit giderlerinizi ve tekrarlayan ödemelerinizi yönetin.

-
@@ -131,7 +130,7 @@ export function SubscriptionsClient({ subscriptions }: { subscriptions: Subscrip - @@ -172,7 +171,7 @@ export function SubscriptionsClient({ subscriptions }: { subscriptions: Subscrip

Yeni Abonelik Ekle

Bu özellik şu an geliştirme aşamasındadır.

- +
diff --git a/app/(dashboard)/calendar/actions.ts b/app/(dashboard)/calendar/actions.ts index ef6cc5c..23780a1 100644 --- a/app/(dashboard)/calendar/actions.ts +++ b/app/(dashboard)/calendar/actions.ts @@ -1,106 +1,67 @@ "use server"; -import { createClient } from "@/lib/supabase/server"; import { revalidatePath } from "next/cache"; +import { cleanText, optionalDate, requiredText } from "@/server/web/form-data"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; const EVENT_TYPES = ["meeting", "focus", "deadline", "personal", "finance"] as const; -function cleanText(value: FormDataEntryValue | null) { - const text = typeof value === "string" ? value.trim() : ""; - return text.length > 0 && text !== "__none" ? text : null; +function eventType(value: FormDataEntryValue | null) { + return typeof value === "string" && EVENT_TYPES.includes(value as (typeof EVENT_TYPES)[number]) + ? value as (typeof EVENT_TYPES)[number] + : "focus"; } -function readType(value: FormDataEntryValue | null) { - const type = typeof value === "string" ? value : "focus"; - return EVENT_TYPES.includes(type as (typeof EVENT_TYPES)[number]) ? type : "focus"; -} - -async function getCurrentUserId() { - const supabase = await createClient(); - const { - data: { user }, - error, - } = await supabase.auth.getUser(); - - if (error || !user) { - throw new Error("Takvim işlemi için giriş yapmış kullanıcı bulunamadı."); - } - - return { supabase, userId: user.id }; -} - -function readPayload(formData: FormData) { +function payload(formData: FormData) { return { - title: cleanText(formData.get("title")), + title: requiredText(formData.get("title"), "Etkinlik başlığı zorunludur."), description: cleanText(formData.get("description")), - type: readType(formData.get("type")), - starts_at: cleanText(formData.get("starts_at")), - ends_at: cleanText(formData.get("ends_at")), - client_id: cleanText(formData.get("client_id")), - project_id: cleanText(formData.get("project_id")), - task_id: cleanText(formData.get("task_id")), + type: eventType(formData.get("type")), + startsAt: optionalDate(formData.get("starts_at")), + endsAt: optionalDate(formData.get("ends_at")), + clientId: cleanText(formData.get("client_id")), + projectId: cleanText(formData.get("project_id")), + taskId: cleanText(formData.get("task_id")), + }; +} + +function completeRelations( + value: ReturnType, + service: Awaited>["service"], + actor: Awaited>["actor"], +) { + const task = value.taskId ? service.listTasks(actor).find((item) => item.id === value.taskId) : null; + const projectId = value.projectId ?? task?.projectId ?? null; + const project = projectId ? service.getProject(actor, projectId) : null; + return { + ...value, + projectId, + clientId: value.clientId ?? task?.clientId ?? project?.clientId ?? null, }; } export async function createCalendarEventRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const payload = readPayload(formData); - - if (!payload.title || !payload.starts_at) { - throw new Error("Etkinlik başlığı ve başlangıç zamanı zorunludur."); - } - - const { error } = await supabase.from("calendar_events").insert({ - user_id: userId, - ...payload, - }); - - if (error) { - throw new Error(`Etkinlik eklenemedi: ${error.message}`); - } - + const backend = await requireFreelancerBackend(); + const value = completeRelations(payload(formData), backend.service, backend.actor); + if (!value.startsAt) throw new Error("Etkinlik başlangıç zamanı zorunludur."); + backend.service.createCalendarEvent(backend.actor, value); revalidatePath("/calendar"); } export async function updateCalendarEventRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const id = cleanText(formData.get("id")); - const payload = readPayload(formData); - - if (!id || !payload.title || !payload.starts_at) { - throw new Error("Etkinlik güncellemek için başlık, başlangıç ve kayıt kimliği zorunludur."); - } - - const { error } = await supabase - .from("calendar_events") - .update(payload) - .eq("id", id) - .eq("user_id", userId); - - if (error) { - throw new Error(`Etkinlik güncellenemedi: ${error.message}`); - } - + const backend = await requireFreelancerBackend(); + const id = requiredText(formData.get("id"), "Etkinlik kaydı bulunamadı."); + const value = completeRelations(payload(formData), backend.service, backend.actor); + if (!value.startsAt) throw new Error("Etkinlik başlangıç zamanı zorunludur."); + backend.service.updateCalendarEvent(backend.actor, id, value); revalidatePath("/calendar"); } export async function deleteCalendarEventRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const id = cleanText(formData.get("id")); - - if (!id) { - throw new Error("Silinecek etkinlik bulunamadı."); - } - - const { error } = await supabase - .from("calendar_events") - .delete() - .eq("id", id) - .eq("user_id", userId); - - if (error) { - throw new Error(`Etkinlik silinemedi: ${error.message}`); - } - + const { actor, service } = await requireFreelancerBackend(); + service.deleteCalendarEvent( + actor, + requiredText(formData.get("id"), "Silinecek etkinlik bulunamadı."), + ); revalidatePath("/calendar"); } diff --git a/app/(dashboard)/calendar/calendar-client.tsx b/app/(dashboard)/calendar/calendar-client.tsx index 6c4db1b..a2bab19 100644 --- a/app/(dashboard)/calendar/calendar-client.tsx +++ b/app/(dashboard)/calendar/calendar-client.tsx @@ -21,7 +21,7 @@ import { SelectValue, toast, } from "poyraz-ui/molecules"; -import { CalendarDays, Clock, Pencil, Plus, Trash2 } from "lucide-react"; +import { Clock, Pencil, Plus, Trash2 } from "lucide-react"; import { useMemo, useState } from "react"; export type CalendarRelationOption = { @@ -89,17 +89,8 @@ export function CalendarClient({ events, clients, projects, tasks }: CalendarCli return (
-
-
- - Planlama -
-
-

Takvim

-

- Toplantı, odak bloğu, deadline, kişisel ve finans etkinliklerini yönet. -

-
+
+

Takvim

{events.length} etkinlik

- - -
@@ -149,13 +140,15 @@ export function CalendarClient({ events, clients, projects, tasks }: CalendarCli const isSelected = selectedDate === day.key; return ( - + ); })}
@@ -257,7 +250,7 @@ function EventList({
- @@ -309,7 +302,7 @@ function CalendarEventDialog({ return ( - @@ -327,7 +320,7 @@ function CalendarEventDialog({
- diff --git a/app/(dashboard)/calendar/page.tsx b/app/(dashboard)/calendar/page.tsx index 1395ffb..bd40f28 100644 --- a/app/(dashboard)/calendar/page.tsx +++ b/app/(dashboard)/calendar/page.tsx @@ -1,107 +1,39 @@ -import { - CalendarClient, - type CalendarEventItem, - type CalendarRelationOption, - type CalendarTaskOption, -} from "@/app/(dashboard)/calendar/calendar-client"; -import { createClient } from "@/lib/supabase/server"; - -type CalendarEventRow = { - id: string; - title: string; - description: string | null; - type: CalendarEventItem["type"]; - starts_at: string; - ends_at: string | null; - client_id: string | null; - project_id: string | null; - task_id: string | null; - clients: { name: string } | { name: string }[] | null; - projects: { name: string } | { name: string }[] | null; - tasks: { title: string } | { title: string }[] | null; -}; +import { CalendarClient, type CalendarEventItem, type CalendarRelationOption, type CalendarTaskOption } from "@/app/(dashboard)/calendar/calendar-client"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; export default async function CalendarPage() { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); + const { actor, service } = await requireFreelancerBackend(); + const eventRows = service.listCalendarEvents(actor); + const clientRows = service.listClients(actor); + const projectRows = service.listProjects(actor); + const taskRows = service.listTasks(actor); + const clients = new Map(clientRows.map((item) => [item.id, item.name])); + const projects = new Map(projectRows.map((item) => [item.id, item.name])); + const tasks = new Map(taskRows.map((item) => [item.id, item.title])); - if (!user) { - return null; - } - - const [{ data: eventRows }, { data: clientRows }, { data: projectRows }, { data: taskRows }] = - await Promise.all([ - supabase - .from("calendar_events") - .select("id, title, description, type, starts_at, ends_at, client_id, project_id, task_id, clients(name), projects(name), tasks(title)") - .eq("user_id", user.id) - .order("starts_at", { ascending: true }), - supabase - .from("clients") - .select("id, name") - .eq("user_id", user.id) - .neq("status", "archived") - .order("name", { ascending: true }), - supabase - .from("projects") - .select("id, name") - .eq("user_id", user.id) - .neq("status", "cancelled") - .order("name", { ascending: true }), - supabase - .from("tasks") - .select("id, title") - .eq("user_id", user.id) - .neq("status", "done") - .order("created_at", { ascending: false }), - ]); - - const events: CalendarEventItem[] = ((eventRows || []) as unknown as CalendarEventRow[]).map((event) => ({ + const events: CalendarEventItem[] = eventRows.map((event) => ({ id: event.id, title: event.title, description: event.description, - type: normalizeType(event.type), - starts_at: event.starts_at, - ends_at: event.ends_at, - client_id: event.client_id, - project_id: event.project_id, - task_id: event.task_id, - clientName: getRelationName(event.clients), - projectName: getRelationName(event.projects), - taskTitle: getRelationTitle(event.tasks), + type: event.type, + starts_at: event.startsAt.toISOString(), + ends_at: event.endsAt?.toISOString() ?? null, + client_id: event.clientId, + project_id: event.projectId, + task_id: event.taskId, + clientName: event.clientId ? clients.get(event.clientId) ?? null : null, + projectName: event.projectId ? projects.get(event.projectId) ?? null : null, + taskTitle: event.taskId ? tasks.get(event.taskId) ?? null : null, })); + const clientOptions: CalendarRelationOption[] = clientRows + .filter((item) => item.status !== "archived") + .map(({ id, name }) => ({ id, name })); + const projectOptions: CalendarRelationOption[] = projectRows + .filter((item) => item.status !== "cancelled") + .map(({ id, name }) => ({ id, name })); + const taskOptions: CalendarTaskOption[] = taskRows + .filter((item) => item.status !== "done" && item.status !== "cancelled") + .map(({ id, title }) => ({ id, title })); - return ( - - ); -} - -function getRelationName(relation: CalendarEventRow["clients"] | CalendarEventRow["projects"]) { - if (!relation) return null; - return Array.isArray(relation) ? relation[0]?.name || null : relation.name; -} - -function getRelationTitle(relation: CalendarEventRow["tasks"]) { - if (!relation) return null; - return Array.isArray(relation) ? relation[0]?.title || null : relation.title; -} - -function normalizeType(type: string): CalendarEventItem["type"] { - if ( - type === "meeting" || - type === "deadline" || - type === "personal" || - type === "finance" - ) { - return type; - } - - return "focus"; + return ; } diff --git a/app/(dashboard)/chat/actions.ts b/app/(dashboard)/chat/actions.ts new file mode 100644 index 0000000..335530c --- /dev/null +++ b/app/(dashboard)/chat/actions.ts @@ -0,0 +1,36 @@ +"use server"; + +import { requireFreelancerBackend } from "@/server/web/freelancer"; + +export async function listChatSessionsAction() { + const { actor, service } = await requireFreelancerBackend(); + return service.listChatSessions(actor).map((session) => ({ + id: session.id, + title: session.title, + created_at: session.createdAt.toISOString(), + })); +} + +export async function listChatMessagesAction(sessionId: string) { + const { actor, service } = await requireFreelancerBackend(); + return service.listChatMessages(actor, sessionId).map((message) => ({ + id: message.id, + role: message.role, + content: message.content, + })); +} + +export async function createChatSessionAction(title: string) { + const { actor, service } = await requireFreelancerBackend(); + const session = service.createChatSession(actor, { title }); + return { + id: session.id, + title: session.title, + created_at: session.createdAt.toISOString(), + }; +} + +export async function deleteChatSessionAction(sessionId: string) { + const { actor, service } = await requireFreelancerBackend(); + service.deleteChatSession(actor, sessionId); +} diff --git a/app/(dashboard)/chat/page.tsx b/app/(dashboard)/chat/page.tsx index c12fb7a..8e9b0aa 100644 --- a/app/(dashboard)/chat/page.tsx +++ b/app/(dashboard)/chat/page.tsx @@ -1,12 +1,17 @@ "use client"; -import { createClient } from "@/lib/supabase/client"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport, type UIMessage } from "ai"; import { Brain, Loader2, MessageSquare, Plus, Send, Trash2 } from "lucide-react"; import { Button } from "poyraz-ui/atoms"; import { useEffect, useRef, useState } from "react"; import { toast } from "poyraz-ui/molecules"; +import { + createChatSessionAction, + deleteChatSessionAction, + listChatMessagesAction, + listChatSessionsAction, +} from "./actions"; function formatMessageContent(text: string) { if (!text) return null; @@ -38,7 +43,6 @@ type ChatSession = { }; export default function AIChatPage() { - const [supabase] = useState(() => createClient()); const [sessions, setSessions] = useState([]); const [activeSessionId, setActiveSessionId] = useState(null); const [input, setInput] = useState(""); @@ -60,26 +64,17 @@ export default function AIChatPage() { useEffect(() => { async function fetchSessions() { - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) return; - - const { data } = await supabase - .from("chat_sessions") - .select("id, title, created_at") - .eq("user_id", user.id) - .order("created_at", { ascending: false }); - - if (data) { + try { + const data = await listChatSessionsAction(); setSessions(data); setActiveSessionId(data[0]?.id || null); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Sohbetler yüklenemedi."); } } void fetchSessions(); - }, [supabase]); + }, []); useEffect(() => { async function fetchMessages() { @@ -88,23 +83,21 @@ export default function AIChatPage() { return; } - const { data } = await supabase - .from("chat_messages") - .select("id, role, content") - .eq("session_id", activeSessionId) - .order("created_at", { ascending: true }); - - const formattedMessages: UIMessage[] = (data || []).map((message) => ({ - id: message.id, - role: message.role as UIMessage["role"], - parts: [{ type: "text", text: message.content || "" }], - })); - - setMessages(formattedMessages); + try { + const data = await listChatMessagesAction(activeSessionId); + const formattedMessages: UIMessage[] = data.map((message) => ({ + id: message.id, + role: message.role as UIMessage["role"], + parts: [{ type: "text", text: message.content }], + })); + setMessages(formattedMessages); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Mesajlar yüklenemedi."); + } } void fetchMessages(); - }, [activeSessionId, setMessages, supabase]); + }, [activeSessionId, setMessages]); async function handleNewChat() { setActiveSessionId(null); @@ -113,7 +106,12 @@ export default function AIChatPage() { async function handleDeleteSession(id: string, event: React.MouseEvent) { event.stopPropagation(); - await supabase.from("chat_sessions").delete().eq("id", id); + try { + await deleteChatSessionAction(id); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Sohbet silinemedi."); + return; + } const nextSessions = sessions.filter((session) => session.id !== id); setSessions(nextSessions); @@ -134,22 +132,16 @@ export default function AIChatPage() { setInput(""); if (!sessionId) { - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) return; - - const { data: newSession } = await supabase - .from("chat_sessions") - .insert({ - user_id: user.id, - title: currentInput.length > 32 ? `${currentInput.slice(0, 32)}...` : currentInput, - }) - .select("id, title, created_at") - .single(); - - if (!newSession) return; + let newSession: ChatSession; + try { + newSession = await createChatSessionAction( + currentInput.length > 32 ? `${currentInput.slice(0, 32)}...` : currentInput, + ); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Sohbet oluşturulamadı."); + setInput(currentInput); + return; + } sessionId = newSession.id; setActiveSessionId(sessionId); @@ -166,7 +158,7 @@ export default function AIChatPage() { Sohbetler -
) : ( sessions.map((session) => ( - + + +
)) )}
@@ -244,10 +236,9 @@ export default function AIChatPage() {

AI Asistan

-

Kayıtlı verilerin hakkında soru sor.

- @@ -312,11 +303,11 @@ export default function AIChatPage() { disabled={isLoading} /> {isLoading ? ( - ) : ( - )} diff --git a/app/(dashboard)/clients/[id]/actions.ts b/app/(dashboard)/clients/[id]/actions.ts index 374eefc..5d16f4a 100644 --- a/app/(dashboard)/clients/[id]/actions.ts +++ b/app/(dashboard)/clients/[id]/actions.ts @@ -1,49 +1,26 @@ "use server"; -import { createClient } from "@/lib/supabase/server"; import { revalidatePath } from "next/cache"; +import { cleanText, optionalDate, requiredText } from "@/server/web/form-data"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; -function cleanText(value: FormDataEntryValue | null) { - const text = typeof value === "string" ? value.trim() : ""; - return text.length > 0 ? text : null; -} +const ACTIVITY_TYPES = ["note", "call", "meeting", "email"] as const; export async function addClientActivity(clientId: string, formData: FormData) { - const supabase = await createClient(); - const { - data: { user }, - error: userError, - } = await supabase.auth.getUser(); + const { actor, service } = await requireFreelancerBackend(); + const rawType = cleanText(formData.get("type")); + const type = rawType && ACTIVITY_TYPES.includes(rawType as (typeof ACTIVITY_TYPES)[number]) + ? rawType as (typeof ACTIVITY_TYPES)[number] + : "note"; - if (userError || !user) { - throw new Error("Kullanıcı bulunamadı."); - } - - const title = cleanText(formData.get("title")); - if (!title) { - throw new Error("Aktivite başlığı zorunludur."); - } - - const { error } = await supabase.from("client_activities").insert({ - user_id: user.id, - client_id: clientId, - type: formData.get("type") as string || "note", - title, + service.addClientActivity(actor, { + clientId, + type, + title: requiredText(formData.get("title"), "Aktivite başlığı zorunludur."), content: cleanText(formData.get("content")), - activity_date: formData.get("activity_date") as string || new Date().toISOString(), + activityDate: optionalDate(formData.get("activity_date")) ?? new Date(), }); - if (error) { - throw new Error(`Aktivite eklenemedi: ${error.message}`); - } - - // Update client's last_contact_date - await supabase - .from("clients") - .update({ last_contact_date: new Date().toISOString() }) - .eq("id", clientId) - .eq("user_id", user.id); - revalidatePath(`/clients/${clientId}`); - revalidatePath(`/clients`); + revalidatePath("/clients"); } diff --git a/app/(dashboard)/clients/[id]/client-detail-client.tsx b/app/(dashboard)/clients/[id]/client-detail-client.tsx index f5dba37..d018bd8 100644 --- a/app/(dashboard)/clients/[id]/client-detail-client.tsx +++ b/app/(dashboard)/clients/[id]/client-detail-client.tsx @@ -5,8 +5,7 @@ import { format } from "date-fns"; import { tr } from "date-fns/locale"; import { Card, CardContent, Badge, Button, Input, Textarea, Label } from "poyraz-ui/atoms"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, DialogDescription } from "poyraz-ui/molecules"; -import { Phone, Mail, ExternalLink, Calendar, Plus, MessageSquare, Briefcase, FileText, UserPlus, Loader2 } from "lucide-react"; -import Link from "next/link"; +import { Phone, Mail, ExternalLink, Calendar, Plus, MessageSquare, UserPlus, Loader2, Copy } from "lucide-react"; import { toast } from "poyraz-ui/molecules"; import { addClientActivity } from "./actions"; @@ -66,30 +65,28 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai const [isCreatingUser, setIsCreatingUser] = useState(false); const [createUserOpen, setCreateUserOpen] = useState(false); + const [invitationUrl, setInvitationUrl] = useState(null); async function handleCreateUser(e: React.FormEvent) { e.preventDefault(); const formData = new FormData(e.currentTarget); const email = formData.get("email") as string; - const password = formData.get("password") as string; setIsCreatingUser(true); try { const res = await fetch("/api/create-client-user", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password, client_id: client.id }) + body: JSON.stringify({ email, client_id: client.id }) }); const data = await res.json(); if (!res.ok || data.error) { throw new Error(data.error || "Kullanıcı oluşturulamadı."); } - toast.success("Müşteri portal hesabı başarıyla oluşturuldu."); - setCreateUserOpen(false); - // Optional: Refresh page to reflect the new client_auth_id - window.location.reload(); - } catch (err: any) { - toast.error(err.message); + setInvitationUrl(data.invitation.invitationUrl); + toast.success("Güvenli portal daveti oluşturuldu."); + } catch (error: unknown) { + toast.error(error instanceof Error ? error.message : "Davet oluşturulamadı."); } finally { setIsCreatingUser(false); } @@ -105,7 +102,6 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai

{client.name}

- {client.company_name &&

{client.company_name}

}
@@ -116,16 +112,16 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai {!client.client_auth_id && ( - - Müşteri Portalı Hesabı Oluştur + Müşteri Portalına Davet Et - Müşteriniz bu e-posta ve şifre ile sisteme giriş yaparak projelerini takip edebilir. + Müşterin bağlantıyı açıp kendi şifresini belirler. Davet 72 saat geçerlidir ve yalnızca bir kez kullanılabilir.
@@ -133,16 +129,33 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai
-
- - -
+ {invitationUrl ? ( +
+ +
+ + +
+

Bağlantı yalnızca bu ekranda düz metin olarak gösterilir.

+
+ ) : null}
- - + @@ -212,7 +225,7 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai - @@ -248,7 +261,7 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai
- diff --git a/app/(dashboard)/clients/[id]/page.tsx b/app/(dashboard)/clients/[id]/page.tsx index 485736e..4c69f49 100644 --- a/app/(dashboard)/clients/[id]/page.tsx +++ b/app/(dashboard)/clients/[id]/page.tsx @@ -1,34 +1,41 @@ -import { createClient } from "@/lib/supabase/server"; -import { ClientDetailClient, type ClientDetailData, type ClientActivity } from "./client-detail-client"; import { notFound } from "next/navigation"; +import { ClientDetailClient, type ClientDetailData, type ClientActivity } from "./client-detail-client"; +import { DomainError } from "@/server/domain/errors"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; export default async function ClientDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; - const supabase = await createClient(); - const { data: { user } } = await supabase.auth.getUser(); + const { actor, service } = await requireFreelancerBackend(); - if (!user) return null; + let data: { client: ClientDetailData; activities: ClientActivity[] }; + try { + const row = service.getClient(actor, id); + const client: ClientDetailData = { + id: row.id, + name: row.name, + company_name: row.companyName, + email: row.email, + phone: row.phone, + website: row.website, + pipeline_stage: row.pipelineStage, + status: row.status, + notes: row.notes, + client_auth_id: row.authUserId, + }; + const activities: ClientActivity[] = service.listClientActivities(actor, id).map((activity) => ({ + id: activity.id, + type: activity.type, + title: activity.title, + content: activity.content, + activity_date: activity.activityDate.toISOString(), + created_at: activity.createdAt.toISOString(), + })); - const { data: clientData, error } = await supabase - .from("clients") - .select("id, name, company_name, email, phone, website, pipeline_stage, status, notes, client_auth_id") - .eq("id", id) - .eq("user_id", user.id) - .single(); - - if (error || !clientData) { - notFound(); + data = { client, activities }; + } catch (error) { + if (error instanceof DomainError && error.code === "NOT_FOUND") notFound(); + throw error; } - const { data: activitiesData } = await supabase - .from("client_activities") - .select("id, type, title, content, activity_date, created_at") - .eq("client_id", id) - .eq("user_id", user.id) - .order("activity_date", { ascending: false }); - - const client: ClientDetailData = clientData as ClientDetailData; - const activities: ClientActivity[] = (activitiesData || []) as ClientActivity[]; - - return ; + return ; } diff --git a/app/(dashboard)/clients/actions.ts b/app/(dashboard)/clients/actions.ts index 89e6e6b..7391761 100644 --- a/app/(dashboard)/clients/actions.ts +++ b/app/(dashboard)/clients/actions.ts @@ -1,143 +1,66 @@ "use server"; -import { createClient } from "@/lib/supabase/server"; import { revalidatePath } from "next/cache"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; +import { cleanText, requiredText } from "@/server/web/form-data"; const CLIENT_STATUSES = ["active", "paused", "archived"] as const; +const PIPELINE_STAGES = ["lead", "contacted", "proposal_sent", "won", "lost"] as const; -function cleanText(value: FormDataEntryValue | null) { - const text = typeof value === "string" ? value.trim() : ""; - return text.length > 0 ? text : null; -} - -function readStatus(value: FormDataEntryValue | null) { - const status = typeof value === "string" ? value : "active"; - return CLIENT_STATUSES.includes(status as (typeof CLIENT_STATUSES)[number]) - ? status - : "active"; +function enumValue( + value: FormDataEntryValue | string | null, + values: T, + fallback: T[number], +): T[number] { + return typeof value === "string" && values.includes(value) ? value as T[number] : fallback; } function cleanWebsite(value: FormDataEntryValue | null) { - const website = cleanText(value)?.replace(/\s/g, "") || null; - - if (!website) { - return null; - } - - return /^https?:\/\//i.test(website) ? website : `https://${website}`; + const website = cleanText(value)?.replace(/\s/g, "") ?? null; + return website && !/^https?:\/\//i.test(website) ? `https://${website}` : website; } -async function getCurrentUserId() { - const supabase = await createClient(); - const { - data: { user }, - error, - } = await supabase.auth.getUser(); - - if (error || !user) { - throw new Error("Müşteri işlemi için giriş yapmış kullanıcı bulunamadı."); - } - - return { supabase, userId: user.id }; -} - -export async function createClientRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const name = cleanText(formData.get("name")); - - if (!name) { - throw new Error("Müşteri adı zorunludur."); - } - - const { error } = await supabase.from("clients").insert({ - user_id: userId, - name, - company_name: cleanText(formData.get("company_name")), +function readPayload(formData: FormData) { + return { + name: requiredText(formData.get("name"), "Müşteri adı zorunludur."), + companyName: cleanText(formData.get("company_name")), email: cleanText(formData.get("email")), phone: cleanText(formData.get("phone")), website: cleanWebsite(formData.get("website")), - status: readStatus(formData.get("status")), + status: enumValue(formData.get("status"), CLIENT_STATUSES, "active"), notes: cleanText(formData.get("notes")), - pipeline_stage: formData.get("pipeline_stage") ? String(formData.get("pipeline_stage")) : "lead", - next_follow_up_date: cleanText(formData.get("next_follow_up_date")) || null, - }); - - if (error) { - throw new Error(`Müşteri eklenemedi: ${error.message}`); - } + pipelineStage: enumValue(formData.get("pipeline_stage"), PIPELINE_STAGES, "lead"), + nextFollowUpDate: cleanText(formData.get("next_follow_up_date")), + }; +} +export async function createClientRecord(formData: FormData) { + const { actor, service } = await requireFreelancerBackend(); + service.createClient(actor, readPayload(formData)); revalidatePath("/clients"); } export async function updateClientRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const id = cleanText(formData.get("id")); - const name = cleanText(formData.get("name")); - - if (!id || !name) { - throw new Error("Müşteri güncellemek için müşteri adı ve kayıt kimliği zorunludur."); - } - - const { error } = await supabase - .from("clients") - .update({ - name, - company_name: cleanText(formData.get("company_name")), - email: cleanText(formData.get("email")), - phone: cleanText(formData.get("phone")), - website: cleanWebsite(formData.get("website")), - status: readStatus(formData.get("status")), - notes: cleanText(formData.get("notes")), - pipeline_stage: formData.get("pipeline_stage") ? String(formData.get("pipeline_stage")) : "lead", - next_follow_up_date: cleanText(formData.get("next_follow_up_date")) || null, - }) - .eq("id", id) - .eq("user_id", userId); - - if (error) { - throw new Error(`Müşteri güncellenemedi: ${error.message}`); - } - + const { actor, service } = await requireFreelancerBackend(); + const id = requiredText(formData.get("id"), "Müşteri kaydı bulunamadı."); + service.updateClient(actor, id, readPayload(formData)); revalidatePath("/clients"); + revalidatePath(`/clients/${id}`); } export async function archiveClientRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const id = cleanText(formData.get("id")); - - if (!id) { - throw new Error("Arşivlenecek müşteri bulunamadı."); - } - - const { error } = await supabase - .from("clients") - .update({ status: "archived" }) - .eq("id", id) - .eq("user_id", userId); - - if (error) { - throw new Error(`Müşteri arşivlenemedi: ${error.message}`); - } - + const { actor, service } = await requireFreelancerBackend(); + const id = requiredText(formData.get("id"), "Arşivlenecek müşteri bulunamadı."); + service.updateClient(actor, id, { status: "archived" }); revalidatePath("/clients"); + revalidatePath(`/clients/${id}`); } export async function updateClientPipelineStage(id: string, stage: string) { - const { supabase, userId } = await getCurrentUserId(); - - if (!id || !stage) { - throw new Error("Eksik bilgi."); - } - - const { error } = await supabase - .from("clients") - .update({ pipeline_stage: stage }) - .eq("id", id) - .eq("user_id", userId); - - if (error) { - throw new Error(`Aşama güncellenemedi: ${error.message}`); - } - + const { actor, service } = await requireFreelancerBackend(); + service.updateClient(actor, id, { + pipelineStage: enumValue(stage, PIPELINE_STAGES, "lead"), + }); revalidatePath("/clients"); + revalidatePath(`/clients/${id}`); } diff --git a/app/(dashboard)/clients/clients-client.tsx b/app/(dashboard)/clients/clients-client.tsx index c040ffe..37e0034 100644 --- a/app/(dashboard)/clients/clients-client.tsx +++ b/app/(dashboard)/clients/clients-client.tsx @@ -1,7 +1,6 @@ "use client"; import { - archiveClientRecord, createClientRecord, updateClientRecord, updateClientPipelineStage, @@ -28,10 +27,7 @@ import { toast, } from "poyraz-ui/molecules"; import { - Archive, - ExternalLink, Mail, - PauseCircle, Pencil, Phone, Plus, @@ -40,14 +36,13 @@ import { Wallet, Clock, ArrowRight, - type LucideIcon, } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; import { format, isPast, isToday } from "date-fns"; import { tr } from "date-fns/locale"; -import { useEffect } from "react"; import { cn } from "@/lib/utils"; +import { StatCard } from "@/components/system/stat-card"; export type ClientListItem = { id: string; @@ -68,19 +63,13 @@ export type ClientListItem = { client_value_score: number; }; -const statusLabels = { - active: "Aktif", - paused: "Duraklatıldı", - archived: "Arşivlendi", -}; +type ClientPipelineStage = ClientListItem["pipeline_stage"]; -const statusClasses = { - active: "border-emerald-200 bg-emerald-50 text-emerald-700", - paused: "border-amber-200 bg-amber-50 text-amber-700", - archived: "border-zinc-200 bg-zinc-50 text-zinc-600", -}; - -const pipelineStages = [ +const pipelineStages: Array<{ + id: ClientPipelineStage; + label: string; + color: string; +}> = [ { id: "lead", label: "Potansiyel (Lead)", color: "border-slate-200 bg-slate-50 text-slate-700" }, { id: "contacted", label: "İletişime Geçildi", color: "border-blue-200 bg-blue-50 text-blue-700" }, { id: "proposal_sent", label: "Teklif İletildi", color: "border-amber-200 bg-amber-50 text-amber-700" }, @@ -92,26 +81,24 @@ type ClientsClientProps = { clients: ClientListItem[]; totalRevenue: number; activeCount: number; - pausedCount: number; - archivedCount: number; }; export function ClientsClient({ clients, totalRevenue, activeCount, - pausedCount, - archivedCount, }: ClientsClientProps) { const [query, setQuery] = useState(""); const normalizedQuery = query.trim().toLowerCase(); const [draggedClientId, setDraggedClientId] = useState(null); - const [localClients, setLocalClients] = useState(clients); - - useEffect(() => { - setLocalClients(clients); - }, [clients]); + const [pipelineOverrides, setPipelineOverrides] = useState< + Partial> + >({}); + const localClients = clients.map((client) => ({ + ...client, + pipeline_stage: pipelineOverrides[client.id] ?? client.pipeline_stage, + })); function handleDragStart(event: React.DragEvent, clientId: string) { setDraggedClientId(clientId); @@ -119,7 +106,7 @@ export function ClientsClient({ event.dataTransfer.setData("text/plain", clientId); } - async function handleDrop(newStage: string) { + async function handleDrop(newStage: ClientPipelineStage) { if (!draggedClientId) return; const clientId = draggedClientId; @@ -128,15 +115,17 @@ export function ClientsClient({ const client = localClients.find(c => c.id === clientId); if (!client || client.pipeline_stage === newStage) return; - setLocalClients(prev => - prev.map(c => c.id === clientId ? { ...c, pipeline_stage: newStage as any } : c) - ); + const previousStage = client.pipeline_stage; + setPipelineOverrides((current) => ({ ...current, [clientId]: newStage })); try { - await updateClientPipelineStage(clientId, newStage as any); + await updateClientPipelineStage(clientId, newStage); toast.success("Müşteri aşaması güncellendi."); } catch (error) { - setLocalClients(clients); + setPipelineOverrides((current) => ({ + ...current, + [clientId]: previousStage, + })); toast.error( error instanceof Error ? error.message @@ -163,19 +152,10 @@ export function ClientsClient({ return (
-
-
- - CRM & Operasyon -
-
-

- CRM & Müşteriler -

-

- Potansiyel müşterilerini pipeline üzerinden takip et ve müşteri ilişkilerini yönet. -

-
+
+

+ CRM & Müşteriler +

@@ -186,26 +166,26 @@ export function ClientsClient({ label="Potansiyel (Lead)" value={clients.filter(c => c.pipeline_stage === 'lead' || c.pipeline_stage === 'contacted').length.toString()} icon={Users} - iconClassName="bg-blue-50 text-blue-700" + tone="blue" /> c.next_follow_up_date && (isPast(new Date(c.next_follow_up_date)) || isToday(new Date(c.next_follow_up_date)))).length.toString()} icon={Clock} - iconClassName="bg-rose-50 text-rose-700" + tone="rose" />
@@ -337,7 +317,7 @@ function DraggableClientCard({ {client.name}
e.stopPropagation()}> - } /> + } />
{client.company_name &&

{client.company_name}

} @@ -418,11 +398,11 @@ function ClientRow({ client }: { client: ClientListItem }) {
- - Düzenle} /> + Düzenle} />
); @@ -463,9 +443,9 @@ function ClientDialog({ {trigger || ( -
- + + + + +
{ + if (event.key === "ArrowLeft") { + event.preventDefault(); + scrollSummary(-1); + } + if (event.key === "ArrowRight") { + event.preventDefault(); + scrollSummary(1); + } + }} + className="flex snap-x snap-mandatory gap-3 overflow-x-auto scroll-smooth pb-3 [scrollbar-width:none] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 [&::-webkit-scrollbar]:hidden" + > + {summaryCards.map((card) => ( + + ))} +
+
@@ -271,7 +347,7 @@ function TransactionRow({
- @@ -316,7 +392,7 @@ function FinanceDialog({ return ( - @@ -332,7 +408,7 @@ function FinanceDialog({
- @@ -493,29 +569,6 @@ function SelectField({ name, label, defaultValue, children }: { name: string; la ); } -function StatCard({ label, value, tone }: { label: string; value: string; tone: "green" | "rose" | "primary" | "amber" }) { - const toneClass = { - green: "bg-emerald-50 text-emerald-700", - rose: "bg-rose-50 text-rose-700", - primary: "bg-primary/10 text-primary", - amber: "bg-amber-50 text-amber-700", - }[tone]; - - return ( - - -
-

{label}

-

{value}

-
-
- -
-
-
- ); -} - function EmptyState({ hasQuery }: { hasQuery: boolean }) { return (
@@ -591,8 +644,10 @@ function AIFinanceDialog() { throw new Error(data.error || "Bilinmeyen bir hata oluştu."); } setResult(data.text); - } catch (err: any) { - setResult("Hata: " + err.message); + } catch (error) { + setResult( + `Hata: ${error instanceof Error ? error.message : "Bilinmeyen bir hata oluştu."}`, + ); } finally { setLoading(false); } @@ -601,7 +656,7 @@ function AIFinanceDialog() { return ( - @@ -620,7 +675,7 @@ function AIFinanceDialog() {
{!result && !loading && (
- @@ -643,8 +698,8 @@ function AIFinanceDialog() { {result && ( - - + diff --git a/app/(dashboard)/finance/page.tsx b/app/(dashboard)/finance/page.tsx index ff354a8..df90924 100644 --- a/app/(dashboard)/finance/page.tsx +++ b/app/(dashboard)/finance/page.tsx @@ -1,93 +1,34 @@ -import { - FinanceClient, - type FinanceRelationOption, - type FinanceTransactionItem, -} from "@/app/(dashboard)/finance/finance-client"; -import { createClient } from "@/lib/supabase/server"; - -type FinanceRow = { - id: string; - type: "income" | "expense"; - amount: number | string; - currency: string; - transaction_date: string; - category: string | null; - payment_status: "planned" | "pending" | "paid" | "cancelled"; - client_id: string | null; - project_id: string | null; - description: string | null; - clients: { name: string } | { name: string }[] | null; - projects: { name: string } | { name: string }[] | null; -}; +import { FinanceClient, type FinanceRelationOption, type FinanceTransactionItem } from "@/app/(dashboard)/finance/finance-client"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; export default async function FinancePage() { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); + const { actor, service } = await requireFreelancerBackend(); + const rows = service.listFinanceTransactions(actor); + const clientRows = service.listClients(actor); + const projectRows = service.listProjects(actor); + const clients = new Map(clientRows.map((item) => [item.id, item.name])); + const projects = new Map(projectRows.map((item) => [item.id, item.name])); - if (!user) { - return null; - } - - const [{ data: financeRows }, { data: clientRows }, { data: projectRows }] = - await Promise.all([ - supabase - .from("finance_transactions") - .select("id, type, amount, currency, transaction_date, category, payment_status, client_id, project_id, description, clients(name), projects(name)") - .eq("user_id", user.id) - .order("transaction_date", { ascending: false }), - supabase - .from("clients") - .select("id, name") - .eq("user_id", user.id) - .neq("status", "archived") - .order("name", { ascending: true }), - supabase - .from("projects") - .select("id, name, client_id") - .eq("user_id", user.id) - .neq("status", "cancelled") - .order("name", { ascending: true }), - ]); - - const transactions: FinanceTransactionItem[] = ((financeRows || []) as unknown as FinanceRow[]).map((transaction) => ({ + const transactions: FinanceTransactionItem[] = rows.map((transaction) => ({ id: transaction.id, - type: normalizeType(transaction.type), - amount: Number(transaction.amount), + type: transaction.type, + amount: transaction.amountMinor / 100, currency: transaction.currency, - transaction_date: transaction.transaction_date, + transaction_date: transaction.transactionDate, category: transaction.category, - payment_status: normalizePaymentStatus(transaction.payment_status), - client_id: transaction.client_id, - project_id: transaction.project_id, - clientName: getRelationName(transaction.clients), - projectName: getRelationName(transaction.projects), + payment_status: transaction.paymentStatus, + client_id: transaction.clientId, + project_id: transaction.projectId, + clientName: transaction.clientId ? clients.get(transaction.clientId) ?? null : null, + projectName: transaction.projectId ? projects.get(transaction.projectId) ?? null : null, description: transaction.description, })); + const clientOptions: FinanceRelationOption[] = clientRows + .filter((item) => item.status !== "archived") + .map(({ id, name }) => ({ id, name })); + const projectOptions: FinanceRelationOption[] = projectRows + .filter((item) => item.status !== "cancelled") + .map(({ id, name, clientId }) => ({ id, name, client_id: clientId })); - return ( - - ); -} - -function getRelationName(relation: FinanceRow["clients"] | FinanceRow["projects"]) { - if (!relation) return null; - return Array.isArray(relation) ? relation[0]?.name || null : relation.name; -} - -function normalizeType(type: string): FinanceTransactionItem["type"] { - return type === "income" ? "income" : "expense"; -} - -function normalizePaymentStatus(status: string): FinanceTransactionItem["payment_status"] { - if (status === "pending" || status === "paid" || status === "cancelled") { - return status; - } - - return "planned"; + return ; } diff --git a/app/(dashboard)/journal/actions.ts b/app/(dashboard)/journal/actions.ts index d9e512d..b98648b 100644 --- a/app/(dashboard)/journal/actions.ts +++ b/app/(dashboard)/journal/actions.ts @@ -1,106 +1,48 @@ "use server"; -import { createClient } from "@/lib/supabase/server"; import { revalidatePath } from "next/cache"; +import { cleanText, requiredText } from "@/server/web/form-data"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; -function cleanText(value: FormDataEntryValue | null) { - const text = typeof value === "string" ? value.trim() : ""; - return text.length > 0 ? text : null; +function score(value: FormDataEntryValue | null): number | null { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 1 && parsed <= 5 ? parsed : null; } -function readScore(value: FormDataEntryValue | null) { - const score = Number(typeof value === "string" ? value : value?.toString()); - return Number.isInteger(score) && score >= 1 && score <= 5 ? score : null; -} - -async function getCurrentUserId() { - const supabase = await createClient(); - const { - data: { user }, - error, - } = await supabase.auth.getUser(); - - if (error || !user) { - throw new Error("Günlük kaydı için giriş yapmış kullanıcı bulunamadı."); - } - - return { supabase, userId: user.id }; -} - -function readPayload(formData: FormData) { +function payload(formData: FormData) { + const moodScore = score(formData.get("mood_score")); + const energyScore = score(formData.get("energy_score")); + if (!moodScore || !energyScore) throw new Error("Mood ve enerji skorları zorunludur."); return { - log_date: cleanText(formData.get("log_date")) || new Date().toISOString().slice(0, 10), - mood_score: readScore(formData.get("mood_score")), - energy_score: readScore(formData.get("energy_score")), - work_satisfaction_score: readScore(formData.get("work_satisfaction_score")), + entryDate: cleanText(formData.get("log_date")) ?? new Date().toISOString().slice(0, 10), + moodScore, + energyScore, + workSatisfactionScore: score(formData.get("work_satisfaction_score")), note: cleanText(formData.get("note")), }; } export async function createDailyLogRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const payload = readPayload(formData); - - if (!payload.mood_score || !payload.energy_score) { - throw new Error("Mood ve enerji skorları zorunludur."); - } - - const { error } = await supabase - .from("daily_logs") - .upsert( - { - user_id: userId, - ...payload, - }, - { onConflict: "user_id,log_date" }, - ); - - if (error) { - throw new Error(`Günlük kaydı eklenemedi: ${error.message}`); - } - + const { actor, service } = await requireFreelancerBackend(); + service.saveJournalEntry(actor, payload(formData)); revalidatePath("/journal"); } export async function updateDailyLogRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const id = cleanText(formData.get("id")); - const payload = readPayload(formData); - - if (!id || !payload.mood_score || !payload.energy_score) { - throw new Error("Günlük kaydını güncellemek için kayıt kimliği, mood ve enerji skorları zorunludur."); - } - - const { error } = await supabase - .from("daily_logs") - .update(payload) - .eq("id", id) - .eq("user_id", userId); - - if (error) { - throw new Error(`Günlük kaydı güncellenemedi: ${error.message}`); - } - + const { actor, service } = await requireFreelancerBackend(); + service.updateJournalEntry( + actor, + requiredText(formData.get("id"), "Günlük kaydı bulunamadı."), + payload(formData), + ); revalidatePath("/journal"); } export async function deleteDailyLogRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const id = cleanText(formData.get("id")); - - if (!id) { - throw new Error("Silinecek günlük kaydı bulunamadı."); - } - - const { error } = await supabase - .from("daily_logs") - .delete() - .eq("id", id) - .eq("user_id", userId); - - if (error) { - throw new Error(`Günlük kaydı silinemedi: ${error.message}`); - } - + const { actor, service } = await requireFreelancerBackend(); + service.deleteJournalEntry( + actor, + requiredText(formData.get("id"), "Silinecek günlük kaydı bulunamadı."), + ); revalidatePath("/journal"); } diff --git a/app/(dashboard)/journal/journal-client.tsx b/app/(dashboard)/journal/journal-client.tsx index 6ffdfef..e773f77 100644 --- a/app/(dashboard)/journal/journal-client.tsx +++ b/app/(dashboard)/journal/journal-client.tsx @@ -35,8 +35,8 @@ import { XAxis, YAxis, } from "recharts"; -import type { ReactNode } from "react"; import { useMemo, useState } from "react"; +import { StatCard } from "@/components/system/stat-card"; export type DailyLogItem = { id: string; @@ -77,19 +77,10 @@ export function JournalClient({ logs }: JournalClientProps) { return (
-
-
- - Günlük durum -
-
-

- Mood ve enerji -

-

- Günlük ruh hali, enerji ve çalışma memnuniyetini takip ederek kişisel kapasite trendini gör. -

-
+
+

+ Mood ve enerji +

@@ -101,25 +92,25 @@ export function JournalClient({ logs }: JournalClientProps) { } + icon={Smile} tone="primary" /> } + icon={Battery} tone="green" /> } + icon={LineChartIcon} tone="blue" /> } + icon={CalendarDays} tone="amber" />
@@ -138,12 +129,12 @@ export function JournalClient({ logs }: JournalClientProps) {
- + - @@ -275,7 +266,7 @@ function DailyLogDialog({ mode, log }: { mode: "create" | "edit"; log?: DailyLog return ( - @@ -295,7 +286,7 @@ function DailyLogDialog({ mode, log }: { mode: "create" | "edit"; log?: DailyLog
- @@ -326,21 +317,18 @@ function DailyLogFormFields({ log }: { log?: DailyLogItem }) { label="Mood skoru" value={moodScore} onChange={setMoodScore} - tone="primary" />
@@ -361,13 +349,11 @@ function ScorePicker({ label, value, onChange, - tone, }: { name: string; label: string; value: number; onChange: (value: number) => void; - tone: "primary" | "green" | "blue"; }) { return (
@@ -378,18 +364,15 @@ function ScorePicker({
{[1, 2, 3, 4, 5].map((score) => ( - + ))}
@@ -405,39 +388,6 @@ function ScoreBadge({ score, tone }: { score: number; tone: "primary" | "green" return {score}/5 · {scoreLabels[score]}; } -function StatCard({ - label, - value, - icon, - tone, -}: { - label: string; - value: string; - icon: ReactNode; - tone: "primary" | "green" | "blue" | "amber"; -}) { - const toneClass = { - primary: "bg-primary/10 text-primary", - green: "bg-emerald-50 text-emerald-700", - blue: "bg-blue-50 text-blue-700", - amber: "bg-amber-50 text-amber-700", - }[tone]; - - return ( - - -
-

{label}

-

{value}

-
-
- {icon} -
-
-
- ); -} - function EmptyState() { return (
@@ -485,12 +435,6 @@ function average(values: number[]) { return values.reduce((sum, value) => sum + value, 0) / values.length; } -function getScoreActiveClass(tone: "primary" | "green" | "blue") { - if (tone === "green") return "border-emerald-600 bg-emerald-600 text-white"; - if (tone === "blue") return "border-blue-600 bg-blue-600 text-white"; - return "border-primary bg-primary text-primary-foreground"; -} - function formatDate(value: string) { return new Intl.DateTimeFormat("tr-TR", { day: "2-digit", diff --git a/app/(dashboard)/journal/page.tsx b/app/(dashboard)/journal/page.tsx index 483be4e..7fa3a8e 100644 --- a/app/(dashboard)/journal/page.tsx +++ b/app/(dashboard)/journal/page.tsx @@ -1,41 +1,22 @@ import { JournalClient, type DailyLogItem } from "@/app/(dashboard)/journal/journal-client"; -import { createClient } from "@/lib/supabase/server"; - -type DailyLogRow = { - id: string; - log_date: string; - mood_score: number; - energy_score: number; - work_satisfaction_score: number | null; - note: string | null; -}; +import { requireFreelancerBackend } from "@/server/web/freelancer"; export default async function JournalPage() { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) { - return null; - } - - const { data: logRows } = await supabase - .from("daily_logs") - .select("id, log_date, mood_score, energy_score, work_satisfaction_score, note") - .eq("user_id", user.id) - .order("log_date", { ascending: false }) - .limit(180); - - const logs: DailyLogItem[] = ((logRows || []) as DailyLogRow[]).map((log) => ({ - id: log.id, - log_date: log.log_date, - mood_score: Number(log.mood_score), - energy_score: Number(log.energy_score), - work_satisfaction_score: - typeof log.work_satisfaction_score === "number" ? Number(log.work_satisfaction_score) : null, - note: log.note, - })); + const { actor, service } = await requireFreelancerBackend(); + const logs: DailyLogItem[] = service.listJournalEntries(actor) + .slice(0, 180) + .flatMap((entry) => + entry.moodScore == null || entry.energyScore == null + ? [] + : [{ + id: entry.id, + log_date: entry.entryDate, + mood_score: entry.moodScore, + energy_score: entry.energyScore, + work_satisfaction_score: entry.workSatisfactionScore, + note: entry.note, + }], + ); return ; } diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 67e50a4..59b7cd6 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -1,53 +1,43 @@ import { DashboardShell } from "@/components/layout/dashboard-shell"; -import { createClient } from "@/lib/supabase/server"; +import { domainActorFromSession } from "@/server/auth/domain-actor"; +import { requireFreelancer } from "@/server/auth/session"; +import { getPublicBranding } from "@/server/branding/runtime"; +import { getUserPreferences } from "@/server/settings/preferences"; export default async function DashboardLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); + const context = await requireFreelancer(); + const { user, profile } = context; + const branding = getPublicBranding(); + const preferences = getUserPreferences(domainActorFromSession(context)); + const displayName = profile.displayName || user.name || user.email.split("@")[0] || "Neta Kullanıcısı"; - const { data: profile } = user - ? await supabase - .from("profiles") - .select("first_name, last_name, avatar_url, role") - .eq("id", user.id) - .maybeSingle() - : { data: null }; - - if (profile?.role === "client") { - const { redirect } = await import("next/navigation"); - redirect("/portal"); - } - - const fallbackName = user?.email?.split("@")[0] ?? "Neta Kullanıcısı"; - const displayName = - [profile?.first_name, profile?.last_name].filter(Boolean).join(" ") || - fallbackName; - - const shortName = displayName - .split(" ") - .filter(Boolean) - .slice(0, 2) - .map((part) => part[0]?.toUpperCase()) - .join("") - .slice(0, 2) || "MS"; + const shortName = + displayName + .split(" ") + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join("") + .slice(0, 2) || "MS"; return ( {children} diff --git a/app/(dashboard)/loading.tsx b/app/(dashboard)/loading.tsx index ce5e869..fa4d12d 100644 --- a/app/(dashboard)/loading.tsx +++ b/app/(dashboard)/loading.tsx @@ -1,5 +1,4 @@ -import { Skeleton } from "@/components/ui/skeleton"; -import { Card, CardContent } from "poyraz-ui/atoms"; +import { Card, CardContent, Skeleton } from "poyraz-ui/atoms"; export default function DashboardLoading() { return ( diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index 34ac59d..89fb10d 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -1,85 +1,35 @@ -import { createClient } from "@/lib/supabase/server"; -import { DashboardClient } from "./dashboard-client"; -import { redirect } from "next/navigation"; +import { DashboardClient, type DashboardData } from "./dashboard-client"; +import { parseDashboardRange, resolveDashboardRange } from "@/server/services/analytics-range"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; -export const metadata = { - title: "Dashboard - Neta", -}; +export const metadata = { title: "Dashboard" }; export default async function DashboardPage({ searchParams, }: { - searchParams: { [key: string]: string | string[] | undefined }; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }) { - const supabase = await createClient(); - const { data: { user } } = await supabase.auth.getUser(); + const params = await searchParams; + const range = parseDashboardRange(params.range); + const { actor, service } = await requireFreelancerBackend(); + const result = service.getFreelancerDashboard(actor, resolveDashboardRange(range)); - if (!user) { - redirect("/login"); - } - - const range = typeof searchParams.range === "string" ? searchParams.range : "this_month"; - - const now = new Date(); - let startDate = new Date(); - let endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59); // default to end of month - - if (range === "today") { - startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0); - endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59); - } else if (range === "this_week") { - // Reset `now` because setDate mutates - const tempNow = new Date(); - const firstDay = new Date(tempNow.setDate(tempNow.getDate() - tempNow.getDay() + (tempNow.getDay() === 0 ? -6 : 1))); - firstDay.setHours(0, 0, 0, 0); - startDate = firstDay; - endDate = new Date(firstDay.getTime()); - endDate.setDate(endDate.getDate() + 6); - endDate.setHours(23, 59, 59, 999); - } else if (range === "this_month") { - startDate = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0); - endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59); - } else if (range === "this_year") { - startDate = new Date(now.getFullYear(), 0, 1, 0, 0, 0); - endDate = new Date(now.getFullYear(), 11, 31, 23, 59, 59); - } - - // Fetch metrics using RPC - const { data: metricsData } = await supabase.rpc('get_dashboard_metrics', { - p_start_date: startDate.toISOString(), - p_end_date: endDate.toISOString() - }); - - // Fetch limited recent data - const [ - { data: projects }, - { data: clients }, - ] = await Promise.all([ - supabase - .from("projects") - .select("id, status, name, created_at") - .order("created_at", { ascending: false }) - .limit(5), - supabase - .from("clients") - .select("id, name, company_name, created_at") - .order("created_at", { ascending: false }) - .limit(5), - ]); - - const dashboardData = { - metrics: metricsData || { - netProfit: 0, - activeProjectsCount: 0, - completedTasksCount: 0, - avgMood: "0.0", - financeTrend: [], - moodTrend: [] - }, - projects: projects || [], - clients: clients || [], - range + const data: DashboardData = { + metrics: result.metrics, + projects: result.projects.map((project) => ({ + id: project.id, + status: project.status, + name: project.name, + created_at: project.createdAt.toISOString(), + })), + clients: result.clients.map((client) => ({ + id: client.id, + name: client.name, + company_name: client.companyName ?? "", + created_at: client.createdAt.toISOString(), + })), + range, }; - return ; + return ; } diff --git a/app/(dashboard)/projects/[id]/loading.tsx b/app/(dashboard)/projects/[id]/loading.tsx index af52d0f..6e93a1c 100644 --- a/app/(dashboard)/projects/[id]/loading.tsx +++ b/app/(dashboard)/projects/[id]/loading.tsx @@ -1,5 +1,4 @@ -import { Skeleton } from "@/components/ui/skeleton"; -import { Card, CardContent } from "poyraz-ui/atoms"; +import { Card, CardContent, Skeleton } from "poyraz-ui/atoms"; export default function ProjectDetailLoading() { return ( diff --git a/app/(dashboard)/projects/[id]/page.tsx b/app/(dashboard)/projects/[id]/page.tsx index da178ba..eaa509d 100644 --- a/app/(dashboard)/projects/[id]/page.tsx +++ b/app/(dashboard)/projects/[id]/page.tsx @@ -1,240 +1,97 @@ +import { notFound } from "next/navigation"; import { ProjectDetailClient, type ProjectDetail, type ProjectDetailTaskItem, type ProjectFinanceItem, type ProjectPlanningSectionItem, + type ProjectRevisionItem, } from "@/app/(dashboard)/projects/[id]/project-detail-client"; -import { createServiceRoleClient } from "@/lib/supabase/admin"; -import { createClient } from "@/lib/supabase/server"; -import { notFound } from "next/navigation"; +import { DomainError } from "@/server/domain/errors"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; -type ProjectRow = { - id: string; - client_id: string | null; - name: string; - type: "client_project" | "side_project"; - description: string | null; - status: "planning" | "active" | "paused" | "completed" | "cancelled"; - start_date: string | null; - due_date: string | null; - budget_amount: number | string | null; - currency: string; - progress: number; - progress_type: "manual" | "auto" | null; - revision_quota: number | null; - cover_image_path: string | null; - cover_image_alt: string | null; - clients: { name: string } | { name: string }[] | null; -}; - -type SectionRow = ProjectPlanningSectionItem; - -type TaskRow = { - id: string; - title: string; - status: string | null; - priority: string | null; - due_at: string | null; - is_public_to_client: boolean | null; -}; - -type FinanceRow = { - id: string; - type: string; - amount: number | string; - currency: string; - payment_status: string; - transaction_date: string; - category: string | null; -}; - -export default async function ProjectDetailPage({ - params, -}: { - params: Promise<{ id: string }>; -}) { +export default async function ProjectDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); + const { actor, service } = await requireFreelancerBackend(); - if (!user) { - return null; - } - - const [{ data: projectRow }, { data: sectionRows }, { data: taskRows }, { data: financeRows }, { data: revisionRows }] = - await Promise.all([ - supabase - .from("projects") - .select( - "id, client_id, name, type, description, status, start_date, due_date, budget_amount, currency, progress, progress_type, revision_quota, cover_image_path, cover_image_alt, clients(name)", - ) - .eq("id", id) - .eq("user_id", user.id) - .maybeSingle(), - supabase - .from("project_planning_sections") - .select("id, project_id, category, title, content, sort_order") - .eq("project_id", id) - .eq("user_id", user.id) - .order("sort_order", { ascending: true }) - .order("created_at", { ascending: true }), - supabase - .from("tasks") - .select("id, title, status, priority, due_at, is_public_to_client") - .eq("project_id", id) - .eq("user_id", user.id) - .order("created_at", { ascending: false }), - supabase - .from("finance_transactions") - .select("id, type, amount, currency, payment_status, transaction_date, category") - .eq("project_id", id) - .eq("user_id", user.id) - .order("transaction_date", { ascending: false }), - supabase - .from("project_revisions") - .select("id, description, status, created_at, requested_by") - .eq("project_id", id) - .order("created_at", { ascending: false }), - ]); - - if (!projectRow) { - notFound(); - } - - const projectData = projectRow as unknown as ProjectRow; - const coverImageUrl = projectData.cover_image_path - ? await createProjectImageUrl(projectData.cover_image_path) - : null; - - const project: ProjectDetail = { - id: projectData.id, - client_id: projectData.client_id, - clientName: getClientName(projectData.clients), - name: projectData.name, - type: normalizeProjectType(projectData.type), - description: projectData.description, - status: normalizeProjectStatus(projectData.status), - start_date: projectData.start_date, - due_date: projectData.due_date, - budget_amount: - projectData.budget_amount === null ? null : Number(projectData.budget_amount), - currency: projectData.currency, - progress: Number(projectData.progress || 0), - progress_type: projectData.progress_type === "auto" ? "auto" : "manual", - revision_quota: Number(projectData.revision_quota || 0), - cover_image_alt: projectData.cover_image_alt, - coverImageUrl, + let data: { + project: ProjectDetail; + sections: ProjectPlanningSectionItem[]; + tasks: ProjectDetailTaskItem[]; + financeTransactions: ProjectFinanceItem[]; + revisions: ProjectRevisionItem[]; }; + try { + const row = service.getProject(actor, id); + const client = row.clientId ? service.getClient(actor, row.clientId) : null; + const project: ProjectDetail = { + id: row.id, + client_id: row.clientId, + clientName: client?.name ?? null, + name: row.name, + type: row.type, + description: row.description, + status: row.status, + start_date: row.startDate, + due_date: row.dueDate, + budget_amount: row.budgetAmountMinor == null ? null : row.budgetAmountMinor / 100, + currency: row.currency, + progress: row.progress, + progress_type: row.progressType, + revision_quota: row.revisionQuota, + cover_image_alt: row.coverImageAlt, + coverImageUrl: row.legacyCoverImagePath, + }; + const sections: ProjectPlanningSectionItem[] = service.listPlanningSections(actor, id).map((section) => ({ + id: section.id, + project_id: section.projectId, + category: section.category, + title: section.title, + content: section.content, + sort_order: section.sortOrder, + })); + const tasks: ProjectDetailTaskItem[] = service.listTasks(actor, id) + .filter((task) => task.status !== "cancelled") + .map((task) => ({ + id: task.id, + title: task.title, + status: task.status as ProjectDetailTaskItem["status"], + priority: task.priority, + due_at: task.dueAt?.toISOString() ?? null, + is_public_to_client: task.isPublicToClient, + })); + const financeTransactions: ProjectFinanceItem[] = service.listFinanceTransactions(actor) + .filter((transaction) => transaction.projectId === id) + .map((transaction) => ({ + id: transaction.id, + type: transaction.type, + amount: transaction.amountMinor / 100, + currency: transaction.currency, + payment_status: transaction.paymentStatus, + transaction_date: transaction.transactionDate, + category: transaction.category, + })); + const revisions = service.listRevisions(actor, id).map((revision) => ({ + id: revision.id, + description: revision.description, + status: revision.status, + created_at: revision.createdAt.toISOString(), + requested_by: revision.requestedByUserId, + })); - const sections = ((sectionRows || []) as unknown as SectionRow[]).map((section) => ({ - ...section, - category: normalizeSectionCategory(section.category), - sort_order: Number(section.sort_order || 0), - })); - const tasks: ProjectDetailTaskItem[] = ((taskRows || []) as TaskRow[]).map((task) => ({ - id: task.id, - title: task.title, - status: normalizeTaskStatus(task.status), - priority: normalizeTaskPriority(task.priority), - due_at: task.due_at, - is_public_to_client: task.is_public_to_client || false, - })); - const revisions = revisionRows || []; - const financeTransactions: ProjectFinanceItem[] = ((financeRows || []) as FinanceRow[]).map( - (transaction) => ({ - id: transaction.id, - type: transaction.type === "income" ? "income" : "expense", - amount: Number(transaction.amount || 0), - currency: transaction.currency, - payment_status: normalizePaymentStatus(transaction.payment_status), - transaction_date: transaction.transaction_date, - category: transaction.category, - }), - ); + data = { project, sections, tasks, financeTransactions, revisions }; + } catch (error) { + if (error instanceof DomainError && error.code === "NOT_FOUND") notFound(); + throw error; + } return ( ); } - -async function createProjectImageUrl(path: string) { - const admin = createServiceRoleClient(); - const { data } = await admin.storage - .from("project-assets") - .createSignedUrl(path, 60 * 15); - - return data?.signedUrl || null; -} - -function getClientName(client: ProjectRow["clients"]) { - if (!client) return null; - return Array.isArray(client) ? client[0]?.name || null : client.name; -} - -function normalizeProjectType(type: string): ProjectDetail["type"] { - return type === "side_project" ? "side_project" : "client_project"; -} - -function normalizeProjectStatus(status: string): ProjectDetail["status"] { - if ( - status === "active" || - status === "paused" || - status === "completed" || - status === "cancelled" - ) { - return status; - } - - return "planning"; -} - -function normalizeSectionCategory(category: string): ProjectPlanningSectionItem["category"] { - if ( - category === "problem" || - category === "goal" || - category === "audience" || - category === "scope" || - category === "design_system" || - category === "color_palette" || - category === "typography" || - category === "assets" || - category === "notes" - ) { - return category; - } - - return "overview"; -} - -function normalizeTaskStatus(status: string | null): ProjectDetailTaskItem["status"] { - if (status === "in_progress" || status === "done") { - return status; - } - - return "todo"; -} - -function normalizeTaskPriority(priority: string | null): ProjectDetailTaskItem["priority"] { - if (priority === "low" || priority === "high" || priority === "urgent") { - return priority; - } - - return "medium"; -} - -function normalizePaymentStatus(status: string): ProjectFinanceItem["payment_status"] { - if (status === "pending" || status === "paid" || status === "cancelled") { - return status; - } - - return "planned"; -} diff --git a/app/(dashboard)/projects/[id]/project-detail-client.tsx b/app/(dashboard)/projects/[id]/project-detail-client.tsx index 95aa268..84d0d4a 100644 --- a/app/(dashboard)/projects/[id]/project-detail-client.tsx +++ b/app/(dashboard)/projects/[id]/project-detail-client.tsx @@ -46,7 +46,8 @@ import { Trash2, Wallet, } from "lucide-react"; -import { useEffect, useState, useTransition, type DragEvent } from "react"; +import Image from "next/image"; +import { useState, useTransition, type DragEvent } from "react"; export type ProjectDetail = { id: string; @@ -105,12 +106,20 @@ export type ProjectFinanceItem = { category: string | null; }; +export type ProjectRevisionItem = { + id: string; + description: string; + status: "pending" | "in_progress" | "completed" | "rejected"; + created_at: string; + requested_by: string; +}; + type ProjectDetailClientProps = { project: ProjectDetail; sections: ProjectPlanningSectionItem[]; tasks: ProjectDetailTaskItem[]; financeTransactions: ProjectFinanceItem[]; - revisions: any[]; + revisions: ProjectRevisionItem[]; }; const typeLabels = { @@ -198,7 +207,7 @@ export function ProjectDetailClient({
-
-

- {project.description || "Bu proje için kısa açıklama eklenmedi."} -

@@ -226,7 +232,7 @@ export function ProjectDetailClient({ } pendingChildren="Tamamlanıyor" @@ -242,11 +248,14 @@ export function ProjectDetailClient({ {project.coverImageUrl ? ( -
- + {project.cover_image_alt
) : ( @@ -342,16 +351,29 @@ export function ProjectDetailClient({ ); } -function RevisionsPanel({ projectId, revisions }: { projectId: string; revisions: any[] }) { +function RevisionsPanel({ + projectId, + revisions, +}: { + projectId: string; + revisions: ProjectRevisionItem[]; +}) { const [isUpdating, setIsUpdating] = useState(false); - async function handleStatusChange(id: string, status: string) { + async function handleStatusChange( + id: string, + status: ProjectRevisionItem["status"], + ) { setIsUpdating(true); try { const { updateRevisionStatus } = await import("@/app/(dashboard)/projects/actions"); await updateRevisionStatus(id, projectId, status); - } catch (err: any) { - console.error(err); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Revizyon durumu güncellenemedi.", + ); } finally { setIsUpdating(false); } @@ -373,7 +395,12 @@ function RevisionsPanel({ projectId, revisions }: { projectId: string; revisions
} aria-label="Sil" /> @@ -505,9 +532,9 @@ function SectionDialog({ return ( -
- @@ -594,26 +621,31 @@ function TaskPanel({ tasks: ProjectDetailTaskItem[]; }) { const [view, setView] = useState<"list" | "kanban">("list"); - const [localTasks, setLocalTasks] = useState(tasks); + const [statusOverrides, setStatusOverrides] = useState< + Partial> + >({}); const [pendingTaskIds, setPendingTaskIds] = useState>(new Set()); const [, startTransition] = useTransition(); - - useEffect(() => { - setLocalTasks(tasks); - }, [tasks]); + const localTasks = tasks.map((task) => ({ + ...task, + status: statusOverrides[task.id] ?? task.status, + })); function handleTaskStatusChange(taskId: string, status: ProjectDetailTaskItem["status"]) { - const previousTasks = localTasks; + const previousStatus = localTasks.find((task) => task.id === taskId)?.status; setPendingTask(taskId, true); - setLocalTasks((currentTasks) => - currentTasks.map((task) => (task.id === taskId ? { ...task, status } : task)), - ); + setStatusOverrides((current) => ({ ...current, [taskId]: status })); startTransition(() => { void updateTaskStatusRecord(taskId, status, projectId) .catch((error) => { - setLocalTasks(previousTasks); + setStatusOverrides((current) => { + const next = { ...current }; + if (previousStatus) next[taskId] = previousStatus; + else delete next[taskId]; + return next; + }); toast.error( error instanceof Error ? error.message @@ -652,19 +684,19 @@ function TaskPanel({
- -
{task.status !== "done" ? ( - @@ -926,7 +959,7 @@ function ProjectSettingsDialog({ project }: { project: ProjectDetail }) {
)} {progressType === "auto" && ( -

İlerleme yüzdesi "Görevler" sekmesindeki görevlerin tamamlanma durumuna göre otomatik hesaplanacaktır.

+

İlerleme yüzdesi "Görevler" sekmesindeki görevlerin tamamlanma durumuna göre otomatik hesaplanacaktır.

)}
@@ -942,7 +975,7 @@ function ProjectSettingsDialog({ project }: { project: ProjectDetail }) {
- @@ -977,7 +1010,7 @@ function ProjectTaskDialog({ return ( - @@ -1091,7 +1124,7 @@ function ProjectTaskDialog({
- @@ -1217,10 +1250,10 @@ function TabButton({ children: React.ReactNode; }) { return ( - -
- @@ -844,7 +808,7 @@ function AIProjectRiskDialog({ projectId }: { projectId?: string }) {
{!result && !loading && (
- @@ -867,8 +831,8 @@ function AIProjectRiskDialog({ projectId }: { projectId?: string }) { {result && ( - - + diff --git a/app/(dashboard)/settings/actions.ts b/app/(dashboard)/settings/actions.ts index c241422..7d21ede 100644 --- a/app/(dashboard)/settings/actions.ts +++ b/app/(dashboard)/settings/actions.ts @@ -1,90 +1,308 @@ -'use server' +"use server"; -import { revalidatePath } from 'next/cache' +import { eq } from "drizzle-orm"; +import { cookies, headers } from "next/headers"; +import { revalidatePath } from "next/cache"; +import { auth } from "@/server/auth/auth"; +import { + COLOR_MODE_COOKIE, + COLOR_MODE_COOKIE_MAX_AGE, +} from "@/lib/color-mode"; +import { getServerConfig } from "@/server/config"; +import { getBrandingService } from "@/server/branding/runtime"; +import { getSqliteConnection } from "@/server/db/client"; +import { appProfiles } from "@/server/db/schema"; +import { domainActorFromSession } from "@/server/auth/domain-actor"; +import { getFileService } from "@/server/files/runtime"; +import { getPublicAiSettings, updateAiSettings } from "@/server/settings/ai"; +import { + getUserPreferences, + updateColorModePreference, +} from "@/server/settings/preferences"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; +import { cleanText } from "@/server/web/form-data"; -import { createServiceRoleClient } from '@/lib/supabase/admin' -import { createClient } from '@/lib/supabase/server' +export async function loadSettings() { + const { context, actor } = await requireFreelancerBackend(); + const [firstName = "", ...lastNameParts] = context.profile.displayName.trim().split(/\s+/); + const ai = getPublicAiSettings(actor); + const preferences = getUserPreferences(actor); + const branding = getBrandingService().getPublic(); -type ProfileUpdateData = { - first_name: string - last_name: string - avatar_url?: string + return { + firstName, + lastName: lastNameParts.join(" "), + avatarUrl: context.user.image ?? "", + aiProvider: ai.provider, + hasApiKey: ai.hasApiKey, + colorMode: preferences.colorMode, + workspaceName: branding.organizationName ?? branding.applicationName, + metaTitle: branding.applicationName, + shortName: branding.shortName, + primaryColor: branding.primaryColor, + lightLogoUrl: branding.lightLogoUrl ?? "", + darkLogoUrl: branding.darkLogoUrl ?? "", + faviconUrl: branding.iconUrl ?? "", + hasCustomLightLogo: Boolean(branding.lightLogoFileId), + hasCustomDarkLogo: Boolean(branding.darkLogoFileId), + hasCustomFavicon: Boolean(branding.iconFileId), + }; } export async function updateProfile(formData: FormData) { - const supabase = await createClient() - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return { error: 'Kullanıcı bulunamadı.' } - } - - const firstName = formData.get('firstName') as string - const lastName = formData.get('lastName') as string - const avatarFile = formData.get('avatar') as File | null - - let avatarUrl: string | undefined - - if (avatarFile && avatarFile.size > 0) { - const fileExt = avatarFile.name.split('.').pop() - const fileName = `${user.id}/${Math.random()}.${fileExt}` - const admin = createServiceRoleClient() - - const { error: uploadError } = await admin.storage - .from('avatars') - .upload(fileName, avatarFile, { upsert: true }) - - if (uploadError) { - return { - error: `Profil fotoğrafı yüklenirken hata oluştu: ${uploadError.message}`, - } + try { + const { context } = await requireFreelancerBackend(); + const firstName = cleanText(formData.get("firstName")); + const lastName = cleanText(formData.get("lastName")); + if (!firstName || !lastName || firstName.length > 80 || lastName.length > 120) { + return { error: "Ad ve soyad zorunludur." }; } - const { - data: { publicUrl }, - } = admin.storage.from('avatars').getPublicUrl(fileName) + const displayName = `${firstName} ${lastName}`; + await auth.api.updateUser({ + headers: await headers(), + body: { name: displayName }, + }); + getSqliteConnection().db + .update(appProfiles) + .set({ displayName, updatedAt: new Date() }) + .where(eq(appProfiles.authUserId, context.user.id)) + .run(); - avatarUrl = publicUrl + const avatar = formData.get("avatar"); + if (avatar instanceof File && avatar.size > 0) { + getFileService().upload(domainActorFromSession(context), { + kind: "avatar", + originalName: avatar.name, + claimedMimeType: avatar.type, + bytes: new Uint8Array(await avatar.arrayBuffer()), + }); + } + + revalidatePath("/settings"); + revalidatePath("/", "layout"); + return { success: true }; + } catch (error) { + return { error: error instanceof Error ? error.message : "Profil güncellenemedi." }; } - - const updateData: ProfileUpdateData = { - first_name: firstName, - last_name: lastName, - } - - if (avatarUrl) { - updateData.avatar_url = avatarUrl - } - - const { error } = await supabase.from('profiles').upsert({ - id: user.id, - ...updateData, - }) - - if (error) { - return { error: `Profil güncellenirken hata oluştu: ${error.message}` } - } - - revalidatePath('/settings') - return { success: true } } export async function updatePassword(formData: FormData) { - const supabase = await createClient() - const password = formData.get('password') as string + const currentPassword = cleanText(formData.get("currentPassword")); + const newPassword = cleanText(formData.get("password")); - if (!password || password.length < 6) { - return { error: 'Şifre en az 6 karakter olmalıdır.' } + if (!currentPassword || !newPassword || newPassword.length < 8) { + return { error: "Mevcut şifre zorunludur; yeni şifre en az 8 karakter olmalıdır." }; } - const { error } = await supabase.auth.updateUser({ password }) - - if (error) { - return { error: `Şifre güncellenirken hata oluştu: ${error.message}` } + try { + await requireFreelancerBackend(); + await auth.api.changePassword({ + headers: await headers(), + body: { + currentPassword, + newPassword, + revokeOtherSessions: true, + }, + }); + return { success: true }; + } catch { + return { error: "Mevcut şifre doğrulanamadı veya şifre güncellenemedi." }; } +} - return { success: true } +export async function saveAiSettings(provider: string, apiKey: string) { + try { + const { actor } = await requireFreelancerBackend(); + const settings = updateAiSettings(actor, { provider, apiKey }); + revalidatePath("/settings"); + return { success: true, hasApiKey: settings.hasApiKey }; + } catch (error) { + return { error: error instanceof Error ? error.message : "Ayarlar kaydedilemedi." }; + } +} + +export async function saveColorMode(colorMode: string) { + try { + const { actor } = await requireFreelancerBackend(); + const preferences = updateColorModePreference(actor, { colorMode }); + const config = getServerConfig(); + + (await cookies()).set(COLOR_MODE_COOKIE, preferences.colorMode, { + httpOnly: false, + maxAge: COLOR_MODE_COOKIE_MAX_AGE, + path: "/", + sameSite: "lax", + secure: config.secureCookies, + }); + + revalidatePath("/", "layout"); + return { success: true, colorMode: preferences.colorMode }; + } catch (error) { + return { error: error instanceof Error ? error.message : "Tema tercihi kaydedilemedi." }; + } +} + +export async function saveGeneralSettings(formData: FormData) { + const uploadedFileIds: string[] = []; + let brandingCommitted = false; + let actorForCleanup: Awaited>["actor"] | null = null; + + try { + const { actor } = await requireFreelancerBackend(); + actorForCleanup = actor; + + const workspaceName = cleanText(formData.get("workspaceName")); + const metaTitle = cleanText(formData.get("metaTitle")); + const shortName = cleanText(formData.get("shortName")); + const primaryColor = cleanText(formData.get("primaryColor"))?.toUpperCase() ?? ""; + if (!workspaceName || workspaceName.length > 120) { + return { error: "Workspace adı 1-120 karakter arasında olmalıdır." }; + } + if (!metaTitle || metaTitle.length > 80) { + return { error: "Tarayıcı başlığı 1-80 karakter arasında olmalıdır." }; + } + if (!shortName || shortName.length > 24) { + return { error: "Kısa uygulama adı 1-24 karakter arasında olmalıdır." }; + } + if (!/^#[0-9A-F]{6}$/.test(primaryColor)) { + return { error: "Ana renk #RRGGBB formatında olmalıdır." }; + } + + const brandingService = getBrandingService(); + const current = brandingService.getPublic(); + const lightLogoFileId = await uploadBrandingFile(formData, "lightLogo", "branding_logo", actor); + if (lightLogoFileId) uploadedFileIds.push(lightLogoFileId); + const darkLogoFileId = await uploadBrandingFile(formData, "darkLogo", "branding_logo", actor); + if (darkLogoFileId) uploadedFileIds.push(darkLogoFileId); + const iconFileId = await uploadBrandingFile(formData, "favicon", "branding_icon", actor); + if (iconFileId) uploadedFileIds.push(iconFileId); + + const updated = brandingService.update(actor, { + applicationName: metaTitle, + shortName, + organizationName: workspaceName, + primaryColor, + ...(lightLogoFileId ? { lightLogoFileId } : {}), + ...(darkLogoFileId ? { darkLogoFileId } : {}), + ...(iconFileId ? { iconFileId } : {}), + }); + brandingCommitted = true; + + deleteSupersededBrandingFiles(actor, current, updated); + + revalidateBrandingPaths(); + return { + success: true, + workspaceName: updated.organizationName ?? updated.applicationName, + metaTitle: updated.applicationName, + shortName: updated.shortName, + primaryColor: updated.primaryColor, + lightLogoUrl: updated.lightLogoUrl ?? "", + darkLogoUrl: updated.darkLogoUrl ?? "", + faviconUrl: updated.iconUrl ?? "", + hasCustomLightLogo: Boolean(updated.lightLogoFileId), + hasCustomDarkLogo: Boolean(updated.darkLogoFileId), + hasCustomFavicon: Boolean(updated.iconFileId), + }; + } catch (error) { + if (actorForCleanup && !brandingCommitted) { + deleteBrandingFilesBestEffort(actorForCleanup, uploadedFileIds); + } + return { error: error instanceof Error ? error.message : "Genel ayarlar kaydedilemedi." }; + } +} + +type BrandingAsset = "lightLogo" | "darkLogo" | "favicon"; + +export async function removeBrandingAsset(asset: BrandingAsset) { + try { + const { actor } = await requireFreelancerBackend(); + const brandingService = getBrandingService(); + const current = brandingService.getPublic(); + const fieldByAsset = { + lightLogo: "lightLogoFileId", + darkLogo: "darkLogoFileId", + favicon: "iconFileId", + } as const; + if (!(asset in fieldByAsset)) { + return { error: "Geçersiz marka görseli." }; + } + const updated = brandingService.update(actor, { [fieldByAsset[asset]]: null }); + + deleteSupersededBrandingFiles(actor, current, updated); + revalidateBrandingPaths(); + return { + success: true, + lightLogoUrl: updated.lightLogoUrl ?? "", + darkLogoUrl: updated.darkLogoUrl ?? "", + faviconUrl: updated.iconUrl ?? "", + hasCustomLightLogo: Boolean(updated.lightLogoFileId), + hasCustomDarkLogo: Boolean(updated.darkLogoFileId), + hasCustomFavicon: Boolean(updated.iconFileId), + }; + } catch (error) { + return { error: error instanceof Error ? error.message : "Marka görseli kaldırılamadı." }; + } +} + +async function uploadBrandingFile( + formData: FormData, + field: "lightLogo" | "darkLogo" | "favicon", + kind: "branding_logo" | "branding_icon", + actor: Awaited>["actor"], +): Promise { + const file = formData.get(field); + if (!(file instanceof File) || file.size === 0) return null; + + return getFileService().upload(actor, { + kind, + originalName: file.name, + claimedMimeType: file.type, + bytes: new Uint8Array(await file.arrayBuffer()), + }).id; +} + +function deleteSupersededBrandingFiles( + actor: Awaited>["actor"], + previous: ReturnType["getPublic"]>, + next: ReturnType["getPublic"]>, +): void { + const activeFileIds = new Set([ + next.lightLogoFileId, + next.darkLogoFileId, + next.iconFileId, + ].filter((id): id is string => Boolean(id))); + + deleteBrandingFilesBestEffort( + actor, + [ + previous.lightLogoFileId, + previous.darkLogoFileId, + previous.iconFileId, + ], + activeFileIds, + ); +} + +function deleteBrandingFilesBestEffort( + actor: Awaited>["actor"], + fileIds: Array, + exceptIds: ReadonlySet = new Set(), +): void { + const uniqueFileIds = new Set(fileIds.filter((id): id is string => Boolean(id && !exceptIds.has(id)))); + for (const fileId of uniqueFileIds) { + try { + getFileService().delete(actor, fileId); + } catch { + // The branding update is authoritative; orphan cleanup can safely be retried later. + } + } +} + +function revalidateBrandingPaths(): void { + revalidatePath("/", "layout"); + revalidatePath("/settings"); + revalidatePath("/portal", "layout"); + revalidatePath("/manifest.webmanifest"); } diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index cdcd174..1f156fc 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -1,16 +1,75 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { AlertTriangle, Blocks, Brain, Key, Save, Shield, User } from "lucide-react"; -import { updatePassword, updateProfile } from "./actions"; -import { createClient } from "@/lib/supabase/client"; -import { Button, Card, CardContent, Input, Label } from "poyraz-ui/atoms"; +import Image from "next/image"; +import { + Blocks, + Brain, + ImageIcon, + Key, + Monitor, + Moon, + Palette, + Save, + Shield, + Sun, + Trash2, + Upload, + User, +} from "lucide-react"; +import { + loadSettings, + removeBrandingAsset, + saveAiSettings, + saveColorMode, + saveGeneralSettings, + updatePassword, + updateProfile, +} from "./actions"; +import { + Button, + Card, + CardContent, + Input, + Label, + RadioGroup, + RadioGroupItem, +} from "poyraz-ui/atoms"; import { toast } from "poyraz-ui/molecules"; +import { applyColorMode } from "@/components/theme/color-mode-sync"; +import { isColorMode, type ColorMode } from "@/lib/color-mode"; type AiProvider = "groq" | "ollama" | "openai" | "gemini"; +type BrandingAsset = "lightLogo" | "darkLogo" | "favicon"; + +const colorModeOptions = [ + { + value: "light", + label: "Açık", + description: "Her zaman aydınlık renk paletini kullanır.", + icon: Sun, + }, + { + value: "dark", + label: "Koyu", + description: "Her zaman koyu renk paletini kullanır.", + icon: Moon, + }, + { + value: "system", + label: "Sistem", + description: "Cihazınızın görünüm tercihini otomatik takip eder.", + icon: Monitor, + }, +] satisfies Array<{ + value: ColorMode; + label: string; + description: string; + icon: typeof Sun; +}>; export default function SettingsPage() { - const [activeTab, setActiveTab] = useState("AI Preferences"); + const [activeTab, setActiveTab] = useState("Genel"); // Profile States const [firstName, setFirstName] = useState(""); @@ -23,11 +82,33 @@ export default function SettingsPage() { // AI States const [aiProvider, setAiProvider] = useState("gemini"); const [apiKey, setApiKey] = useState(""); - - // Supabase - const [supabase] = useState(() => createClient()); + const [hasApiKey, setHasApiKey] = useState(false); + const [colorMode, setColorMode] = useState("system"); + const [isSavingColorMode, setIsSavingColorMode] = useState(false); + const [workspaceName, setWorkspaceName] = useState("Neta"); + const [metaTitle, setMetaTitle] = useState("Neta"); + const [shortName, setShortName] = useState("Neta"); + const [primaryColor, setPrimaryColor] = useState("#C81E1E"); + const [assetUrls, setAssetUrls] = useState>({ + lightLogo: "", + darkLogo: "", + favicon: "", + }); + const [pendingAssetUrls, setPendingAssetUrls] = useState>({ + lightLogo: "", + darkLogo: "", + favicon: "", + }); + const [customAssets, setCustomAssets] = useState>({ + lightLogo: false, + darkLogo: false, + favicon: false, + }); + const [isSavingBranding, setIsSavingBranding] = useState(false); + const assetObjectUrlRefs = useRef>>({}); const tabs = [ + { name: "Genel", icon: Palette }, { name: "Profile & Account", icon: User }, { name: "AI Preferences", icon: Brain }, { name: "Security", icon: Shield }, @@ -37,42 +118,42 @@ export default function SettingsPage() { let isActive = true; const fetchData = async () => { - const { data: { user } } = await supabase.auth.getUser(); - if (!user || !isActive) return; - - // 1. Fetch Profile - const { data: profile } = await supabase - .from("profiles") - .select("*") - .eq("id", user.id) - .single(); - - if (profile && isActive) { - setFirstName(profile.first_name || ""); - setLastName(profile.last_name || ""); - setAvatarUrl(profile.avatar_url || ""); - } - - // 2. Fetch User Settings from Supabase - const { data: settings } = await supabase - .from("app_settings") - .select("*") - .eq("user_id", user.id) - .single(); - - if (settings && isActive) { - setAiProvider((settings.ai_provider as AiProvider) || "gemini"); - setApiKey(settings.api_key || ""); - - // Also sync to local storage for existing API route calls if they use it - localStorage.setItem("mindspace_ai_provider", settings.ai_provider || "gemini"); - localStorage.setItem("mindspace_api_key", settings.api_key || ""); - } + const settings = await loadSettings(); + if (!isActive) return; + setFirstName(settings.firstName); + setLastName(settings.lastName); + setAvatarUrl(settings.avatarUrl); + setAiProvider(settings.aiProvider); + setHasApiKey(settings.hasApiKey); + setColorMode(settings.colorMode); + setWorkspaceName(settings.workspaceName); + setMetaTitle(settings.metaTitle); + setShortName(settings.shortName); + setPrimaryColor(settings.primaryColor); + setAssetUrls({ + lightLogo: settings.lightLogoUrl, + darkLogo: settings.darkLogoUrl, + favicon: settings.faviconUrl, + }); + setCustomAssets({ + lightLogo: settings.hasCustomLightLogo, + darkLogo: settings.hasCustomDarkLogo, + favicon: settings.hasCustomFavicon, + }); }; void fetchData(); return () => { isActive = false; }; - }, [supabase]); + }, []); + + useEffect(() => { + const objectUrls = assetObjectUrlRefs.current; + return () => { + for (const objectUrl of Object.values(objectUrls)) { + if (objectUrl) URL.revokeObjectURL(objectUrl); + } + }; + }, []); const handleProfileAction = async (formData: FormData) => { const response = await updateProfile(formData); @@ -96,76 +177,347 @@ export default function SettingsPage() { }; const handleSaveAI = async () => { + const response = await saveAiSettings(aiProvider, apiKey); + if (response.error) { + toast.error(response.error); + return; + } + setHasApiKey(Boolean(response.hasApiKey)); + setApiKey(""); + toast.success("Yapay Zeka ayarları kaydedildi!"); + }; + + const handleColorModeChange = async (value: string) => { + if (!isColorMode(value) || value === colorMode || isSavingColorMode) return; + + const previousColorMode = colorMode; + setColorMode(value); + applyColorMode(value); + setIsSavingColorMode(true); + try { - const { data: { user } } = await supabase.auth.getUser(); - if (!user) throw new Error("Giriş yapılmamış"); + const response = await saveColorMode(value); + if (response.error) { + setColorMode(previousColorMode); + applyColorMode(previousColorMode); + toast.error(response.error); + return; + } - // Save to Supabase app_settings table - const { error } = await supabase - .from("app_settings") - .upsert({ - user_id: user.id, - ai_provider: aiProvider, - ai_model: null, // Reset to allow default model fallback - api_key: apiKey, - updated_at: new Date().toISOString() - }, { onConflict: 'user_id' }); + toast.success("Görünüm tercihi kaydedildi."); + } finally { + setIsSavingColorMode(false); + } + }; - if (error) throw error; + const handleBrandingAssetChange = ( + asset: BrandingAsset, + event: React.ChangeEvent, + ) => { + const previousObjectUrl = assetObjectUrlRefs.current[asset]; + if (previousObjectUrl) URL.revokeObjectURL(previousObjectUrl); + const file = event.target.files?.[0]; + const objectUrl = file ? URL.createObjectURL(file) : ""; + assetObjectUrlRefs.current[asset] = objectUrl || undefined; + setPendingAssetUrls((current) => ({ ...current, [asset]: objectUrl })); + }; - // Sync to localStorage as a redundant fallback - localStorage.setItem("mindspace_ai_provider", aiProvider); - localStorage.setItem("mindspace_api_key", apiKey); + const handleGeneralSettingsAction = async (formData: FormData) => { + setIsSavingBranding(true); + try { + const response = await saveGeneralSettings(formData); + if (response.error) { + toast.error(response.error); + return; + } - toast.success("Yapay Zeka ayarları kaydedildi!"); - } catch (e: any) { - console.error(e); - toast.error("Hata oluştu, veritabanına kaydedilemedi."); + toast.success("Genel görünüm ve marka ayarları güncellendi."); + window.location.reload(); + } finally { + setIsSavingBranding(false); + } + }; + + const handleRemoveBrandingAsset = async (asset: BrandingAsset) => { + setIsSavingBranding(true); + try { + const response = await removeBrandingAsset(asset); + if (response.error) { + toast.error(response.error); + return; + } + + toast.success("Marka görseli kaldırıldı."); + window.location.reload(); + } finally { + setIsSavingBranding(false); } }; return (
-
-
- Settings / {activeTab} -
-
-

- Ayarlar -

-

- Profilinizi, güvenlik ayarlarınızı ve yapay zeka tercihlerinizi yönetin. -

-
+
+

+ Ayarlar +

-
+
{/* Settings Sidebar */} -
+
{tabs.map((tab) => { const Icon = tab.icon; return ( - + ) })}
{/* Settings Content Area */}
+ {activeTab === "Genel" && ( + + +
+

Genel görünüm ve marka

+

+ Web ve mobil istemcilerde kullanılan workspace kimliğini, marka görsellerini ve tema tercihlerini yönetin. +

+
+ + +
+
+
+ + setWorkspaceName(event.target.value)} + minLength={1} + maxLength={120} + required + /> +

+ Firma, freelance marka veya çalışma alanı adınız. +

+
+ +
+ + setMetaTitle(event.target.value)} + minLength={1} + maxLength={80} + required + /> +

+ Sekme başlıklarında ve uygulama metadata bilgisinde kullanılır. +

+
+
+ +
+ + setShortName(event.target.value)} + minLength={1} + maxLength={24} + required + /> +

+ Mobil uygulama ve ana ekrana ekleme alanlarında kullanılan kısa ad. +

+
+
+ +
+
+ + +
+
+ +
+
+

Tarayıcı ikonu

+

+ Favicon, web manifest ve mobil instance metadata alanlarında kullanılır. +

+
+ +
+ +
+
+ +

+ Bir renk seçin; vurgu, focus ve yumuşak yüzey tonları otomatik türetilir. +

+
+ +
+ setPrimaryColor(event.target.value.toUpperCase())} + aria-label="Ana renk seçici" + className="h-11 w-16 shrink-0 cursor-pointer p-1" + /> + setPrimaryColor(event.target.value.toUpperCase())} + pattern="^#[0-9A-Fa-f]{6}$" + maxLength={7} + placeholder="#C81E1E" + required + className="font-mono uppercase" + /> +
+
+ +
+ +
+ + +
+
+

Tema görünümü

+

+ Arayüzün açık, koyu veya cihazınızla uyumlu görünmesini seçin. +

+
+ + + {colorModeOptions.map((option) => { + const Icon = option.icon; + const selected = colorMode === option.value; + + return ( + + ); + })} + + +

+ {isSavingColorMode + ? "Görünüm tercihi kaydediliyor…" + : "Değişiklik tüm sayfalara anında uygulanır."} +

+
+
+
+ )} + {activeTab === "Profile & Account" && ( @@ -173,7 +525,14 @@ export default function SettingsPage() {
{avatarUrl ? ( - Avatar + Avatar ) : (
@@ -197,7 +556,7 @@ export default function SettingsPage() {
-
@@ -211,12 +570,16 @@ export default function SettingsPage() {

Şifre İşlemleri

+
+ + +
- +
-
@@ -269,14 +632,14 @@ export default function SettingsPage() { type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} - placeholder="sk-..." + placeholder={hasApiKey ? "Kayıtlı anahtarı korumak için boş bırakın" : "sk-..."} />
)}
-
@@ -300,3 +663,93 @@ export default function SettingsPage() {
); } + +type BrandingAssetFieldProps = { + asset: BrandingAsset; + inputId: string; + name: string; + title: string; + description?: string; + accept: string; + currentUrl: string; + pendingUrl: string; + hasCustomAsset: boolean; + previewTone: "light" | "dark" | "neutral"; + compact?: boolean; + disabled: boolean; + onChange: (asset: BrandingAsset, event: React.ChangeEvent) => void; + onRemove: (asset: BrandingAsset) => void; +}; + +function BrandingAssetField({ + asset, + inputId, + name, + title, + description, + accept, + currentUrl, + pendingUrl, + hasCustomAsset, + previewTone, + compact = false, + disabled, + onChange, + onRemove, +}: BrandingAssetFieldProps) { + const previewUrl = pendingUrl || (hasCustomAsset ? currentUrl : ""); + const previewClassName = { + light: "bg-white", + dark: "bg-neutral-950", + neutral: "bg-muted/40", + }[previewTone]; + + return ( +
+
+
+ + {description ?

{description}

: null} +
+ onChange(asset, event)} + className="cursor-pointer" + /> + {hasCustomAsset ? ( + + ) : null} +
+ +
+ {previewUrl ? ( + {`${title} + ) : ( +
+
+ )} +
+
+ ); +} diff --git a/app/(dashboard)/tasks/actions.ts b/app/(dashboard)/tasks/actions.ts index c63c0cb..aae4e35 100644 --- a/app/(dashboard)/tasks/actions.ts +++ b/app/(dashboard)/tasks/actions.ts @@ -1,193 +1,88 @@ "use server"; -import { createClient } from "@/lib/supabase/server"; import { revalidatePath } from "next/cache"; +import { cleanText, optionalDate, requiredText } from "@/server/web/form-data"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; const TASK_STATUSES = ["todo", "in_progress", "done"] as const; const TASK_PRIORITIES = ["low", "medium", "high", "urgent"] as const; -function cleanText(value: FormDataEntryValue | null) { - const text = typeof value === "string" ? value.trim() : ""; - return text.length > 0 ? text : null; +function enumValue(value: FormDataEntryValue | string | null, values: T, fallback: T[number]): T[number] { + return typeof value === "string" && values.includes(value) ? value as T[number] : fallback; } -function cleanRelationId(value: FormDataEntryValue | null) { - const id = cleanText(value); - return id && id !== "__none" ? id : null; +function minutes(value: FormDataEntryValue | null): number | null { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : null; } -function readStatus(value: FormDataEntryValue | null) { - const status = typeof value === "string" ? value : "todo"; - return TASK_STATUSES.includes(status as (typeof TASK_STATUSES)[number]) - ? status - : "todo"; -} - -function readPriority(value: FormDataEntryValue | null) { - const priority = typeof value === "string" ? value : "medium"; - return TASK_PRIORITIES.includes(priority as (typeof TASK_PRIORITIES)[number]) - ? priority - : "medium"; -} - -function readMinutes(value: FormDataEntryValue | null) { - const number = Number(value); - return Number.isFinite(number) && number >= 0 ? Math.round(number) : null; -} - -async function getCurrentUserId() { - const supabase = await createClient(); - const { - data: { user }, - error, - } = await supabase.auth.getUser(); - - if (error || !user) { - throw new Error("Görev işlemi için giriş yapmış kullanıcı bulunamadı."); - } - - return { supabase, userId: user.id }; -} - -function readPayload(formData: FormData) { +function payload(formData: FormData) { + const dueAt = optionalDate(formData.get("due_at")); return { - title: cleanText(formData.get("title")), + title: requiredText(formData.get("title"), "Görev başlığı zorunludur."), description: cleanText(formData.get("description")), - status: readStatus(formData.get("status")), - priority: readPriority(formData.get("priority")), - client_id: cleanRelationId(formData.get("client_id")), - project_id: cleanRelationId(formData.get("project_id")), - due_at: cleanText(formData.get("due_at")), - estimated_minutes: readMinutes(formData.get("estimated_minutes")), - actual_minutes: readMinutes(formData.get("actual_minutes")), - is_public_to_client: formData.get("is_public_to_client") === "on", + status: enumValue(formData.get("status"), TASK_STATUSES, "todo"), + priority: enumValue(formData.get("priority"), TASK_PRIORITIES, "medium"), + clientId: cleanText(formData.get("client_id")), + projectId: cleanText(formData.get("project_id")), + scheduledDate: dueAt?.toISOString().slice(0, 10) ?? null, + dueAt, + estimatedMinutes: minutes(formData.get("estimated_minutes")), + actualMinutes: minutes(formData.get("actual_minutes")), + isPublicToClient: formData.get("is_public_to_client") === "on", }; } -export async function createTaskRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const payload = readPayload(formData); - - if (!payload.title) { - throw new Error("Görev başlığı zorunludur."); - } - - const { error } = await supabase.from("tasks").insert({ - user_id: userId, - date: payload.due_at || new Date().toISOString(), - ...payload, - }); - - if (error) { - throw new Error(`Görev eklenemedi: ${error.message}`); - } +function completeRelations( + value: ReturnType, + service: Awaited>["service"], + actor: Awaited>["actor"], +) { + const project = value.projectId ? service.getProject(actor, value.projectId) : null; + return { ...value, clientId: value.clientId ?? project?.clientId ?? null }; +} +function revalidate(projectId?: string | null) { revalidatePath("/tasks"); + revalidatePath("/projects"); + if (projectId) revalidatePath(`/projects/${projectId}`); +} - if (payload.project_id) { - revalidatePath(`/projects/${payload.project_id}`); - } +export async function createTaskRecord(formData: FormData) { + const { actor, service } = await requireFreelancerBackend(); + const value = completeRelations(payload(formData), service, actor); + service.createTask(actor, value); + revalidate(value.projectId); } export async function updateTaskRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const id = cleanText(formData.get("id")); - const payload = readPayload(formData); - - if (!id || !payload.title) { - throw new Error("Görev güncellemek için başlık ve kayıt kimliği zorunludur."); - } - - const { error } = await supabase - .from("tasks") - .update(payload) - .eq("id", id) - .eq("user_id", userId); - - if (error) { - throw new Error(`Görev güncellenemedi: ${error.message}`); - } - - revalidatePath("/tasks"); - - if (payload.project_id) { - revalidatePath(`/projects/${payload.project_id}`); - } + const { actor, service } = await requireFreelancerBackend(); + const id = requiredText(formData.get("id"), "Görev kaydı bulunamadı."); + const value = completeRelations(payload(formData), service, actor); + const current = service.listTasks(actor).find((task) => task.id === id); + service.updateTask(actor, id, value); + revalidate(value.projectId); + if (current?.projectId !== value.projectId) revalidate(current?.projectId); } export async function completeTaskRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const id = cleanText(formData.get("id")); - const projectId = cleanRelationId(formData.get("project_id")); - - if (!id) { - throw new Error("Tamamlanacak görev bulunamadı."); - } - - const { error } = await supabase - .from("tasks") - .update({ status: "done" }) - .eq("id", id) - .eq("user_id", userId); - - if (error) { - throw new Error(`Görev tamamlanamadı: ${error.message}`); - } - - revalidatePath("/tasks"); - - if (projectId) { - revalidatePath(`/projects/${projectId}`); - } + const id = requiredText(formData.get("id"), "Tamamlanacak görev bulunamadı."); + const projectId = cleanText(formData.get("project_id")); + const { actor, service } = await requireFreelancerBackend(); + service.updateTask(actor, id, { status: "done" }); + revalidate(projectId); } export async function updateTaskStatusRecord(taskId: string, status: string, projectId?: string) { - const { supabase, userId } = await getCurrentUserId(); - const nextStatus = readStatus(status); - - if (!taskId) { - throw new Error("Durumu güncellenecek görev bulunamadı."); - } - - const { error } = await supabase - .from("tasks") - .update({ status: nextStatus }) - .eq("id", taskId) - .eq("user_id", userId); - - if (error) { - throw new Error(`Görev durumu güncellenemedi: ${error.message}`); - } - - revalidatePath("/tasks"); - - if (projectId) { - revalidatePath(`/projects/${projectId}`); - } + const { actor, service } = await requireFreelancerBackend(); + service.updateTask(actor, taskId, { status: enumValue(status, TASK_STATUSES, "todo") }); + revalidate(projectId); } export async function deleteTaskRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const id = cleanText(formData.get("id")); - const projectId = cleanRelationId(formData.get("project_id")); - - if (!id) { - throw new Error("Silinecek görev bulunamadı."); - } - - const { error } = await supabase - .from("tasks") - .delete() - .eq("id", id) - .eq("user_id", userId); - - if (error) { - throw new Error(`Görev silinemedi: ${error.message}`); - } - - revalidatePath("/tasks"); - - if (projectId) { - revalidatePath(`/projects/${projectId}`); - } + const id = requiredText(formData.get("id"), "Silinecek görev bulunamadı."); + const projectId = cleanText(formData.get("project_id")); + const { actor, service } = await requireFreelancerBackend(); + service.deleteTask(actor, id); + revalidate(projectId); } diff --git a/app/(dashboard)/tasks/loading.tsx b/app/(dashboard)/tasks/loading.tsx index 57fa88b..13e8654 100644 --- a/app/(dashboard)/tasks/loading.tsx +++ b/app/(dashboard)/tasks/loading.tsx @@ -1,5 +1,4 @@ -import { Skeleton } from "@/components/ui/skeleton"; -import { Card, CardContent } from "poyraz-ui/atoms"; +import { Card, CardContent, Skeleton } from "poyraz-ui/atoms"; export default function TasksLoading() { return ( diff --git a/app/(dashboard)/tasks/page.tsx b/app/(dashboard)/tasks/page.tsx index fe4c70d..b604552 100644 --- a/app/(dashboard)/tasks/page.tsx +++ b/app/(dashboard)/tasks/page.tsx @@ -1,93 +1,37 @@ -import { - TasksClient, - type TaskListItem, - type TaskRelationOption, -} from "@/app/(dashboard)/tasks/tasks-client"; -import { createClient } from "@/lib/supabase/server"; - -type TaskRow = { - id: string; - title: string; - description: string | null; - status: "todo" | "in_progress" | "done"; - priority: "low" | "medium" | "high" | "urgent"; - due_at: string | null; - estimated_minutes: number | null; - actual_minutes: number | null; - client_id: string | null; - project_id: string | null; - created_at: string; - clients: { name: string } | { name: string }[] | null; - projects: { name: string } | { name: string }[] | null; -}; +import { TasksClient, type TaskListItem, type TaskRelationOption } from "@/app/(dashboard)/tasks/tasks-client"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; export default async function TasksPage() { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); + const { actor, service } = await requireFreelancerBackend(); + const taskRows = service.listTasks(actor); + const clientRows = service.listClients(actor); + const projectRows = service.listProjects(actor); + const clientNames = new Map(clientRows.map((item) => [item.id, item.name])); + const projectNames = new Map(projectRows.map((item) => [item.id, item.name])); - if (!user) { - return null; - } - - const [{ data: taskRows }, { data: clientRows }, { data: projectRows }] = - await Promise.all([ - supabase - .from("tasks") - .select( - "id, title, description, status, priority, due_at, estimated_minutes, actual_minutes, client_id, project_id, created_at, clients(name), projects(name)", - ) - .eq("user_id", user.id) - .order("created_at", { ascending: false }), - supabase - .from("clients") - .select("id, name") - .eq("user_id", user.id) - .neq("status", "archived") - .order("name", { ascending: true }), - supabase - .from("projects") - .select("id, name, client_id") - .eq("user_id", user.id) - .neq("status", "cancelled") - .order("name", { ascending: true }), - ]); - - const clients = (clientRows || []) as TaskRelationOption[]; - const projects = (projectRows || []) as TaskRelationOption[]; - const tasks: TaskListItem[] = ((taskRows || []) as unknown as TaskRow[]).map((task) => ({ - id: task.id, - title: task.title, - description: task.description, - status: normalizeStatus(task.status), - priority: normalizePriority(task.priority), - due_at: task.due_at, - estimated_minutes: task.estimated_minutes, - actual_minutes: task.actual_minutes, - client_id: task.client_id, - clientName: getRelationName(task.clients), - project_id: task.project_id, - projectName: getRelationName(task.projects), - created_at: task.created_at, - })); + const tasks: TaskListItem[] = taskRows + .filter((task) => task.status !== "cancelled") + .map((task) => ({ + id: task.id, + title: task.title, + description: task.description, + status: task.status as TaskListItem["status"], + priority: task.priority, + due_at: task.dueAt?.toISOString() ?? null, + estimated_minutes: task.estimatedMinutes, + actual_minutes: task.actualMinutes, + client_id: task.clientId, + clientName: task.clientId ? clientNames.get(task.clientId) ?? null : null, + project_id: task.projectId, + projectName: task.projectId ? projectNames.get(task.projectId) ?? null : null, + created_at: task.createdAt.toISOString(), + })); + const clients: TaskRelationOption[] = clientRows + .filter((client) => client.status !== "archived") + .map(({ id, name }) => ({ id, name })); + const projects: TaskRelationOption[] = projectRows + .filter((project) => project.status !== "cancelled") + .map(({ id, name, clientId }) => ({ id, name, client_id: clientId })); return ; } - -function getRelationName(relation: TaskRow["clients"] | TaskRow["projects"]) { - if (!relation) return null; - return Array.isArray(relation) ? relation[0]?.name || null : relation.name; -} - -function normalizeStatus(status: string): TaskListItem["status"] { - return status === "in_progress" || status === "done" ? status : "todo"; -} - -function normalizePriority(priority: string): TaskListItem["priority"] { - if (priority === "low" || priority === "high" || priority === "urgent") { - return priority; - } - - return "medium"; -} diff --git a/app/(dashboard)/tasks/tasks-client.tsx b/app/(dashboard)/tasks/tasks-client.tsx index bb9bddb..6fbac1e 100644 --- a/app/(dashboard)/tasks/tasks-client.tsx +++ b/app/(dashboard)/tasks/tasks-client.tsx @@ -31,7 +31,7 @@ import { Plus, Trash2, } from "lucide-react"; -import { useEffect, useState, useTransition, type DragEvent } from "react"; +import { useState, useTransition, type DragEvent } from "react"; export type TaskRelationOption = { id: string; @@ -82,29 +82,37 @@ type TasksClientProps = { }; export function TasksClient({ tasks, clients, projects }: TasksClientProps) { - const [localTasks, setLocalTasks] = useState(tasks); + const [statusOverrides, setStatusOverrides] = useState< + Partial> + >({}); + const [deletedTaskIds, setDeletedTaskIds] = useState>(new Set()); const [query, setQuery] = useState(""); const [projectFilter, setProjectFilter] = useState("__all"); const [view, setView] = useState<"list" | "kanban">("list"); const [pendingTaskIds, setPendingTaskIds] = useState>(new Set()); const [, startTransition] = useTransition(); - - useEffect(() => { - setLocalTasks(tasks); - }, [tasks]); + const localTasks = tasks + .filter((task) => !deletedTaskIds.has(task.id)) + .map((task) => ({ + ...task, + status: statusOverrides[task.id] ?? task.status, + })); function handleTaskStatusChange(taskId: string, status: TaskListItem["status"]) { - const previousTasks = localTasks; + const previousStatus = localTasks.find((task) => task.id === taskId)?.status; setPendingTask(taskId, true); - setLocalTasks((currentTasks) => - currentTasks.map((task) => (task.id === taskId ? { ...task, status } : task)), - ); + setStatusOverrides((current) => ({ ...current, [taskId]: status })); startTransition(() => { void updateTaskStatusRecord(taskId, status) .catch((error) => { - setLocalTasks(previousTasks); + setStatusOverrides((current) => { + const next = { ...current }; + if (previousStatus) next[taskId] = previousStatus; + else delete next[taskId]; + return next; + }); toast.error( error instanceof Error ? error.message @@ -118,7 +126,6 @@ export function TasksClient({ tasks, clients, projects }: TasksClientProps) { } function handleTaskDelete(taskId: string) { - const previousTasks = localTasks; const task = localTasks.find((item) => item.id === taskId); const formData = new FormData(); formData.set("id", taskId); @@ -128,12 +135,16 @@ export function TasksClient({ tasks, clients, projects }: TasksClientProps) { } setPendingTask(taskId, true); - setLocalTasks((currentTasks) => currentTasks.filter((item) => item.id !== taskId)); + setDeletedTaskIds((current) => new Set(current).add(taskId)); startTransition(() => { void deleteTaskRecord(formData) .catch((error) => { - setLocalTasks(previousTasks); + setDeletedTaskIds((current) => { + const next = new Set(current); + next.delete(taskId); + return next; + }); toast.error( error instanceof Error ? error.message : "Görev silinemedi.", ); @@ -180,19 +191,10 @@ export function TasksClient({ tasks, clients, projects }: TasksClientProps) { return (
-
-
- - Günlük operasyon -
-
-

- Görevler -

-

- Proje ve müşteri bağlantılı işleri liste veya basit kanban ile takip et. -

-
+
+

+ Görevler +

@@ -236,19 +238,19 @@ export function TasksClient({ tasks, clients, projects }: TasksClientProps) {
- - ) : null} -
-