From 21af4b7a6213c96dbdaa80bfadcb074963150012 Mon Sep 17 00:00:00 2001 From: poyrazavsever Date: Sat, 6 Jun 2026 21:17:36 +0300 Subject: [PATCH] feat: implement Business OS modules for proposals, invoices, and subscriptions with database schema and UI components --- .../business/invoices/invoices-client.tsx | 159 +++++++++++ app/(dashboard)/business/invoices/page.tsx | 43 +++ app/(dashboard)/business/proposals/page.tsx | 41 +++ .../business/proposals/proposals-client.tsx | 159 +++++++++++ .../business/subscriptions/page.tsx | 41 +++ .../subscriptions/subscriptions-client.tsx | 183 +++++++++++++ app/(dashboard)/finance/finance-client.tsx | 10 +- app/(dashboard)/settings/page.tsx | 252 +++++++++--------- config/sidebar.ts | 11 + .../0004_add_business_os_tables.sql | 94 +++++++ 10 files changed, 871 insertions(+), 122 deletions(-) create mode 100644 app/(dashboard)/business/invoices/invoices-client.tsx create mode 100644 app/(dashboard)/business/invoices/page.tsx create mode 100644 app/(dashboard)/business/proposals/page.tsx create mode 100644 app/(dashboard)/business/proposals/proposals-client.tsx create mode 100644 app/(dashboard)/business/subscriptions/page.tsx create mode 100644 app/(dashboard)/business/subscriptions/subscriptions-client.tsx create mode 100644 supabase/migrations/0004_add_business_os_tables.sql diff --git a/app/(dashboard)/business/invoices/invoices-client.tsx b/app/(dashboard)/business/invoices/invoices-client.tsx new file mode 100644 index 0000000..4b44e3d --- /dev/null +++ b/app/(dashboard)/business/invoices/invoices-client.tsx @@ -0,0 +1,159 @@ +"use client"; + +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 { Button, Card, CardContent, Badge } from "poyraz-ui/atoms"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "poyraz-ui/molecules"; + +export type InvoiceRow = { + id: string; + invoice_number: string; + amount: number; + currency: string; + status: "draft" | "sent" | "paid" | "overdue" | "cancelled"; + issue_date: string | null; + due_date: string | null; + clientName: string | null; + projectName: string | null; + created_at: string; +}; + +export function InvoicesClient({ invoices }: { invoices: InvoiceRow[] }) { + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + + const formatCurrency = (amount: number, currency: string) => { + return new Intl.NumberFormat("tr-TR", { style: "currency", currency }).format(amount); + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case "draft": + return Taslak; + case "sent": + return Gönderildi; + case "paid": + return Ödendi; + case "overdue": + return Gecikmiş; + case "cancelled": + return İptal; + default: + return {status}; + } + }; + + return ( +
+
+
+

Faturalar

+

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

+
+ +
+ + + +
+
+ + + + + + + + + + + + + {invoices.length === 0 ? ( + + + + ) : ( + invoices.map((invoice) => ( + + + + + + + + + )) + )} + +
Fatura NoMüşteriTutarDurumDüzenlenme Tarihiİşlemler
+ Henüz hiç fatura bulunmuyor. +
+ {invoice.invoice_number} + {invoice.projectName && ( +
{invoice.projectName}
+ )} +
+ {invoice.clientName || "-"} + + {formatCurrency(invoice.amount, invoice.currency)} + + {getStatusBadge(invoice.status)} + + {invoice.issue_date ? format(new Date(invoice.issue_date), "dd MMM yyyy", { locale: tr }) : "-"} + + + + + + + + Düzenle + + + PDF İndir + + + Gönder + + + Ödendi İşaretle + + + Sil + + + +
+
+
+
+
+ + {isAddModalOpen && ( +
+ + +

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 new file mode 100644 index 0000000..6916fdb --- /dev/null +++ b/app/(dashboard)/business/invoices/page.tsx @@ -0,0 +1,43 @@ +import { createClient } from "@/lib/supabase/server"; +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, + })); + + return ; +} diff --git a/app/(dashboard)/business/proposals/page.tsx b/app/(dashboard)/business/proposals/page.tsx new file mode 100644 index 0000000..ad79318 --- /dev/null +++ b/app/(dashboard)/business/proposals/page.tsx @@ -0,0 +1,41 @@ +import { createClient } from "@/lib/supabase/server"; +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, + })); + + return ; +} diff --git a/app/(dashboard)/business/proposals/proposals-client.tsx b/app/(dashboard)/business/proposals/proposals-client.tsx new file mode 100644 index 0000000..a6d0ac1 --- /dev/null +++ b/app/(dashboard)/business/proposals/proposals-client.tsx @@ -0,0 +1,159 @@ +"use client"; + +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 { Button, Card, CardContent, Badge } from "poyraz-ui/atoms"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "poyraz-ui/molecules"; + +export type ProposalRow = { + id: string; + title: string; + amount: number; + currency: string; + status: "draft" | "sent" | "accepted" | "rejected"; + valid_until: string | null; + clientName: string | null; + projectName: string | null; + created_at: string; +}; + +export function ProposalsClient({ proposals }: { proposals: ProposalRow[] }) { + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + + const formatCurrency = (amount: number, currency: string) => { + return new Intl.NumberFormat("tr-TR", { style: "currency", currency }).format(amount); + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case "draft": + return Taslak; + case "sent": + return Gönderildi; + case "accepted": + return Kabul Edildi; + case "rejected": + return Reddedildi; + default: + return {status}; + } + }; + + return ( +
+ {/* Header */} +
+
+

Teklifler

+

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

+
+ +
+ + {/* List */} + + +
+
+ + + + + + + + + + + + + {proposals.length === 0 ? ( + + + + ) : ( + proposals.map((proposal) => ( + + + + + + + + + )) + )} + +
Teklif AdıMüşteriTutarDurumGeçerlilikİşlemler
+ Henüz hiç teklif bulunmuyor. +
+ {proposal.title} + {proposal.projectName && ( +
{proposal.projectName}
+ )} +
+ {proposal.clientName || "-"} + + {formatCurrency(proposal.amount, proposal.currency)} + + {getStatusBadge(proposal.status)} + + {proposal.valid_until ? format(new Date(proposal.valid_until), "dd MMM yyyy", { locale: tr }) : "-"} + + + + + + + + Düzenle + + + Gönder + + + Kabul Edildi + + + Reddedildi + + + Sil + + + +
+
+
+
+
+ + {/* Add Modal Placeholder */} + {isAddModalOpen && ( +
+ + +

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 new file mode 100644 index 0000000..7410f9a --- /dev/null +++ b/app/(dashboard)/business/subscriptions/page.tsx @@ -0,0 +1,41 @@ +import { createClient } from "@/lib/supabase/server"; +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, + })); + + return ; +} diff --git a/app/(dashboard)/business/subscriptions/subscriptions-client.tsx b/app/(dashboard)/business/subscriptions/subscriptions-client.tsx new file mode 100644 index 0000000..94e326f --- /dev/null +++ b/app/(dashboard)/business/subscriptions/subscriptions-client.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useState } from "react"; +import { format } from "date-fns"; +import { tr } from "date-fns/locale"; +import { CreditCard, Plus, MoreHorizontal, FileEdit, Trash2, StopCircle, RefreshCw } from "lucide-react"; +import { Button, Card, CardContent, Badge } from "poyraz-ui/atoms"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "poyraz-ui/molecules"; + +export type SubscriptionRow = { + id: string; + name: string; + amount: number; + currency: string; + billing_cycle: "monthly" | "yearly" | "weekly"; + status: "active" | "cancelled"; + category: string | null; + next_billing_date: string | null; + created_at: string; +}; + +export function SubscriptionsClient({ subscriptions }: { subscriptions: SubscriptionRow[] }) { + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + + const formatCurrency = (amount: number, currency: string) => { + return new Intl.NumberFormat("tr-TR", { style: "currency", currency }).format(amount); + }; + + const getCycleBadge = (cycle: string) => { + switch (cycle) { + case "monthly": + return "Aylık"; + case "yearly": + return "Yıllık"; + case "weekly": + return "Haftalık"; + default: + return cycle; + } + }; + + const activeMonthlyTotal = subscriptions + .filter(s => s.status === "active") + .reduce((acc, s) => { + let monthlyEquivalent = s.amount; + if (s.billing_cycle === "yearly") monthlyEquivalent = s.amount / 12; + if (s.billing_cycle === "weekly") monthlyEquivalent = s.amount * 4.33; + return acc + monthlyEquivalent; + }, 0); + + return ( +
+
+
+

Abonelikler ve Masraflar

+

Sabit giderlerinizi ve tekrarlayan ödemelerinizi yönetin.

+
+ +
+ +
+ + +
+ +

Aylık Tahmini Gider

+
+

+ {formatCurrency(activeMonthlyTotal, "TRY")} +

+

Aktif aboneliklerin aylık ortalaması

+
+
+
+ + + +
+
+ + + + + + + + + + + + + + {subscriptions.length === 0 ? ( + + + + ) : ( + subscriptions.map((sub) => ( + + + + + + + + + + )) + )} + +
Abonelik AdıKategoriTutarPeriyotDurumSonraki Ödemeİşlemler
+ Henüz hiç abonelik bulunmuyor. +
+ {sub.name} + + {sub.category || "-"} + + {formatCurrency(sub.amount, sub.currency)} + + {getCycleBadge(sub.billing_cycle)} + + {sub.status === "active" ? ( + Aktif + ) : ( + İptal Edildi + )} + + {sub.next_billing_date ? format(new Date(sub.next_billing_date), "dd MMM yyyy", { locale: tr }) : "-"} + + + + + + + + Düzenle + + {sub.status === "active" ? ( + + İptal Et + + ) : ( + + Yeniden Aktifleştir + + )} + + Sil + + + +
+
+
+
+
+ + {isAddModalOpen && ( +
+ + +

Yeni Abonelik Ekle

+

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

+
+ +
+
+
+
+ )} +
+ ); +} diff --git a/app/(dashboard)/finance/finance-client.tsx b/app/(dashboard)/finance/finance-client.tsx index 2865aa5..4253cf3 100644 --- a/app/(dashboard)/finance/finance-client.tsx +++ b/app/(dashboard)/finance/finance-client.tsx @@ -130,10 +130,12 @@ export function FinanceClient({ transactions, clients, projects }: FinanceClient -
+
- + + +
@@ -528,9 +530,11 @@ function calculateSummary(transactions: FinanceTransactionItem[]) { summary.pending += transaction.amount; } summary.net = summary.income - summary.expense; + summary.tax = summary.income * 0.20; // 20% KDV/Vergi tahmini + summary.afterTax = summary.net - summary.tax; return summary; }, - { income: 0, expense: 0, net: 0, pending: 0 }, + { income: 0, expense: 0, net: 0, tax: 0, afterTax: 0, pending: 0 }, ); } diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index be3d701..28d0677 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"; import { User, Bell, Shield, Blocks, Brain, CreditCard, Save, Key, AlertTriangle } from "lucide-react"; import { updatePassword, updateProfile } from "./actions"; import { createClient } from "@/lib/supabase/client"; +import { Button, Card, CardContent, Input, Label } from "poyraz-ui/atoms"; type AiProvider = "groq" | "ollama" | "openai" | "gemini"; @@ -131,16 +132,24 @@ export default function SettingsPage() { }; return ( -
- -
-

- Settings / {activeTab} -

+
+
+
+
+ Settings / {activeTab} +
+
+

+ Ayarlar +

+

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

+
+
-
- +
{/* Settings Sidebar */}
{tabs.map((tab) => { @@ -149,13 +158,13 @@ export default function SettingsPage() { ) @@ -163,132 +172,137 @@ export default function SettingsPage() {
{/* Settings Content Area */} -
- +
{activeTab === "Profile & Account" && ( -
-

User Profile

-
-
- {avatarUrl ? ( - Avatar - ) : ( -
- + + +

Kullanıcı Profili

+ +
+ {avatarUrl ? ( + Avatar + ) : ( +
+ +
+ )} +
+ +
- )} -
- -
-
-
-
- - setFirstName(e.target.value)} className="w-full bg-[#150F1D] border border-white/10 rounded-sm px-3 py-2 text-sm outline-none focus:border-primary/50 transition-colors" /> +
+
+ + setFirstName(e.target.value)} /> +
+
+ + setLastName(e.target.value)} /> +
-
- - setLastName(e.target.value)} className="w-full bg-[#150F1D] border border-white/10 rounded-sm px-3 py-2 text-sm outline-none focus:border-primary/50 transition-colors" /> -
-
-
- - {profileSaveStatus && {profileSaveStatus}} -
- -
+
+ + {profileSaveStatus && {profileSaveStatus}} +
+ +
+
)} {activeTab === "Security" && ( -
-

Şifre İşlemleri

-
-
- - -
-
- - {passwordSaveStatus && {passwordSaveStatus}} -
-
-
+ + +

Şifre İşlemleri

+
+
+ + +
+
+ + {passwordSaveStatus && {passwordSaveStatus}} +
+
+
+
)} {activeTab === "AI Preferences" && ( -
-

AI Assistant Configuration

- -
-
-

Model ve Sağlayıcı Seçimi

-
- - - - -
-
- - {aiProvider !== "ollama" && ( -
-

API Keys

-
-
- - {aiProvider.toUpperCase()} API Key -
- setApiKey(e.target.value)} - placeholder="sk-..." - className="bg-[#0A0710] border border-white/10 rounded-sm px-3 py-2 text-sm text-foreground w-full outline-none focus:border-primary/50" - /> + + +

AI Asistan Konfigürasyonu

+ +
+
+

Model ve Sağlayıcı Seçimi

+
+ + + +
- )} -
- - {aiSaveStatus && {aiSaveStatus}} + {aiProvider !== "ollama" && ( +
+

API Keys

+
+
+ + +
+ setApiKey(e.target.value)} + placeholder="sk-..." + /> +
+
+ )} + +
+ + {aiSaveStatus && {aiSaveStatus}} +
- -
-
+ + )} {["Integrations", "Notifications", "Billing & Plans"].includes(activeTab) && ( -
- -

{activeTab}

-

Bu bölüm şu an geliştirme aşamasındadır.

-
+ + + +

{activeTab}

+

Bu bölüm şu an geliştirme aşamasındadır.

+
+
)}
diff --git a/config/sidebar.ts b/config/sidebar.ts index ae63d8a..3738e13 100644 --- a/config/sidebar.ts +++ b/config/sidebar.ts @@ -8,6 +8,9 @@ import { MessageCircleHeart, Sparkles, Wallet, + FileText, + Receipt, + CreditCard, } from "lucide-react"; export type SidebarNavItem = { @@ -40,6 +43,14 @@ export const sidebarData: SidebarNavGroup[] = [ { title: "Finans", href: "/finance", icon: Wallet }, ], }, + { + title: "BUSINESS OS", + items: [ + { title: "Teklifler", href: "/business/proposals", icon: FileText }, + { title: "Faturalar", href: "/business/invoices", icon: Receipt }, + { title: "Abonelikler", href: "/business/subscriptions", icon: CreditCard }, + ], + }, { title: "KİŞİSEL", items: [{ title: "Günlük", href: "/journal", icon: BookOpenText }], diff --git a/supabase/migrations/0004_add_business_os_tables.sql b/supabase/migrations/0004_add_business_os_tables.sql new file mode 100644 index 0000000..76e2580 --- /dev/null +++ b/supabase/migrations/0004_add_business_os_tables.sql @@ -0,0 +1,94 @@ +-- 0004: Faz 6 - Freelancer Business OS Tables + +-- 1. Proposals +create table if not exists public.proposals ( + id uuid default uuid_generate_v4() primary key, + user_id uuid references auth.users(id) on delete cascade not null, + client_id uuid references public.clients(id) on delete set null, + project_id uuid references public.projects(id) on delete set null, + title text not null, + description text, + amount numeric(12,2) not null default 0, + currency text default 'TRY'::text, + status text default 'draft'::text check (status in ('draft', 'sent', 'accepted', 'rejected')), + valid_until timestamp with time zone, + created_at timestamp with time zone default timezone('utc'::text, now()) not null, + updated_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +-- 2. Contracts +create table if not exists public.contracts ( + id uuid default uuid_generate_v4() primary key, + user_id uuid references auth.users(id) on delete cascade not null, + proposal_id uuid references public.proposals(id) on delete set null, + client_id uuid references public.clients(id) on delete set null, + title text not null, + content text, + status text default 'draft'::text check (status in ('draft', 'active', 'completed', 'cancelled')), + signed_at timestamp with time zone, + created_at timestamp with time zone default timezone('utc'::text, now()) not null, + updated_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +-- 3. Invoices +create table if not exists public.invoices ( + id uuid default uuid_generate_v4() primary key, + user_id uuid references auth.users(id) on delete cascade not null, + client_id uuid references public.clients(id) on delete set null, + project_id uuid references public.projects(id) on delete set null, + invoice_number text not null, + amount numeric(12,2) not null default 0, + tax_rate numeric(5,2) default 0, -- percentage + currency text default 'TRY'::text, + status text default 'draft'::text check (status in ('draft', 'sent', 'paid', 'overdue', 'cancelled')), + issue_date timestamp with time zone default timezone('utc'::text, now()), + due_date timestamp with time zone, + paid_at timestamp with time zone, + created_at timestamp with time zone default timezone('utc'::text, now()) not null, + updated_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +-- 4. Subscriptions +create table if not exists public.subscriptions ( + id uuid default uuid_generate_v4() primary key, + user_id uuid references auth.users(id) on delete cascade not null, + name text not null, + amount numeric(12,2) not null default 0, + currency text default 'TRY'::text, + billing_cycle text default 'monthly'::text check (billing_cycle in ('monthly', 'yearly', 'weekly')), + next_billing_date timestamp with time zone, + status text default 'active'::text check (status in ('active', 'cancelled')), + category text, -- e.g., 'software', 'hosting', 'marketing' + created_at timestamp with time zone default timezone('utc'::text, now()) not null, + updated_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +-- Enable RLS +alter table public.proposals enable row level security; +alter table public.contracts enable row level security; +alter table public.invoices enable row level security; +alter table public.subscriptions enable row level security; + +-- Proposals RLS +create policy "Users can view their own proposals" on public.proposals for select using (auth.uid() = user_id); +create policy "Users can insert their own proposals" on public.proposals for insert with check (auth.uid() = user_id); +create policy "Users can update their own proposals" on public.proposals for update using (auth.uid() = user_id); +create policy "Users can delete their own proposals" on public.proposals for delete using (auth.uid() = user_id); + +-- Contracts RLS +create policy "Users can view their own contracts" on public.contracts for select using (auth.uid() = user_id); +create policy "Users can insert their own contracts" on public.contracts for insert with check (auth.uid() = user_id); +create policy "Users can update their own contracts" on public.contracts for update using (auth.uid() = user_id); +create policy "Users can delete their own contracts" on public.contracts for delete using (auth.uid() = user_id); + +-- Invoices RLS +create policy "Users can view their own invoices" on public.invoices for select using (auth.uid() = user_id); +create policy "Users can insert their own invoices" on public.invoices for insert with check (auth.uid() = user_id); +create policy "Users can update their own invoices" on public.invoices for update using (auth.uid() = user_id); +create policy "Users can delete their own invoices" on public.invoices for delete using (auth.uid() = user_id); + +-- Subscriptions RLS +create policy "Users can view their own subscriptions" on public.subscriptions for select using (auth.uid() = user_id); +create policy "Users can insert their own subscriptions" on public.subscriptions for insert with check (auth.uid() = user_id); +create policy "Users can update their own subscriptions" on public.subscriptions for update using (auth.uid() = user_id); +create policy "Users can delete their own subscriptions" on public.subscriptions for delete using (auth.uid() = user_id);