diff --git a/src/app/(dashboard)/invoices/[id]/components/header-actions.tsx b/src/app/(dashboard)/invoices/[id]/components/header-actions.tsx index 1693043..8c5a1e5 100644 --- a/src/app/(dashboard)/invoices/[id]/components/header-actions.tsx +++ b/src/app/(dashboard)/invoices/[id]/components/header-actions.tsx @@ -2,7 +2,7 @@ import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; -import { Loader2, Pencil, Printer, Trash2 } from "lucide-react"; +import { FileDown, Loader2, Pencil, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -45,9 +45,13 @@ export function InvoiceHeaderActions({ invoice, customers }: Props) { return ( <>
-
+ {/* Ekler */} + {isEdit && service && ( + + )} + {/* Sorumlu personel */} {members.length > 0 && (
diff --git a/src/app/(dashboard)/software/components/assignment-form-sheet.tsx b/src/app/(dashboard)/software/components/assignment-form-sheet.tsx index b48809b..2f5038f 100644 --- a/src/app/(dashboard)/software/components/assignment-form-sheet.tsx +++ b/src/app/(dashboard)/software/components/assignment-form-sheet.tsx @@ -28,6 +28,7 @@ import { updateAssignmentAction, } from "@/lib/appwrite/software-actions"; import { initialSoftwareState } from "@/lib/appwrite/software-types"; +import { AttachmentsPanel } from "@/components/attachments-panel"; import type { AssignmentRow, CustomerOption, SoftwareOption } from "./types"; type Props = { @@ -202,6 +203,14 @@ export function AssignmentFormSheet({ placeholder="Lisans bilgileri, özel koşullar" />
+ + {/* Ekler */} + {isEdit && assignment && ( + + )} diff --git a/src/app/api/files/[attachmentId]/route.ts b/src/app/api/files/[attachmentId]/route.ts new file mode 100644 index 0000000..013cda3 --- /dev/null +++ b/src/app/api/files/[attachmentId]/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +import { BUCKETS, DATABASE_ID, TABLES, type Attachment } from "@/lib/appwrite/schema"; +import { createAdminClient } from "@/lib/appwrite/server"; +import { requireTenant } from "@/lib/appwrite/tenant-guard"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ attachmentId: string }> }, +) { + const { attachmentId } = await params; + + let ctx; + try { + ctx = await requireTenant(); + } catch { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { tablesDB } = createAdminClient(); + + let attachment: Attachment; + try { + attachment = (await tablesDB.getRow( + DATABASE_ID, + TABLES.attachments, + attachmentId, + )) as unknown as Attachment; + } catch { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + if (attachment.tenantId !== ctx.tenantId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const endpoint = (process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT ?? "").replace(/\/$/, ""); + const projectId = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID ?? ""; + const apiKey = process.env.APPWRITE_API_KEY ?? ""; + + const fileUrl = `${endpoint}/storage/buckets/${BUCKETS.entityAttachments}/files/${attachment.fileId}/download`; + + let upstream: Response; + try { + upstream = await fetch(fileUrl, { + headers: { + "X-Appwrite-Project": projectId, + "X-Appwrite-Key": apiKey, + }, + }); + } catch { + return NextResponse.json({ error: "Storage unavailable" }, { status: 502 }); + } + + if (!upstream.ok) { + return NextResponse.json({ error: "File not found" }, { status: 404 }); + } + + const contentType = + upstream.headers.get("Content-Type") || "application/octet-stream"; + const encodedName = encodeURIComponent(attachment.name); + + return new NextResponse(upstream.body, { + headers: { + "Content-Type": contentType, + "Content-Disposition": `attachment; filename*=UTF-8''${encodedName}`, + "Cache-Control": "private, no-store", + }, + }); +} diff --git a/src/app/print/invoices/[id]/auto-print.tsx b/src/app/print/invoices/[id]/auto-print.tsx new file mode 100644 index 0000000..1132b92 --- /dev/null +++ b/src/app/print/invoices/[id]/auto-print.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useEffect } from "react"; + +export function AutoPrint() { + useEffect(() => { + const t = setTimeout(() => window.print(), 600); + return () => clearTimeout(t); + }, []); + return null; +} + +export function PrintActionBar() { + return ( +
+ + +
+ ); +} diff --git a/src/app/print/invoices/[id]/page.tsx b/src/app/print/invoices/[id]/page.tsx new file mode 100644 index 0000000..1fb1207 --- /dev/null +++ b/src/app/print/invoices/[id]/page.tsx @@ -0,0 +1,218 @@ +import { notFound, redirect } from "next/navigation"; + +import { listCustomers } from "@/lib/appwrite/customer-queries"; +import { getInvoice, listInvoiceItems } from "@/lib/appwrite/invoice-queries"; +import { getLogoUrl } from "@/lib/appwrite/storage"; +import { requireTenant } from "@/lib/appwrite/tenant-guard"; +import { formatDate, formatTRY } from "@/lib/format"; + +import { AutoPrint, PrintActionBar } from "./auto-print"; + +const STATUS_LABEL: Record = { + draft: "Taslak", + sent: "Gönderildi", + paid: "Ödendi", + overdue: "Gecikmiş", + cancelled: "İptal", +}; + +export default async function InvoicePrintPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + let ctx; + try { + ctx = await requireTenant(); + } catch { + redirect("/onboarding"); + } + + const invoice = await getInvoice(ctx.tenantId, id); + if (!invoice) notFound(); + + const [items, customers] = await Promise.all([ + listInvoiceItems(ctx.tenantId, id), + listCustomers(ctx.tenantId), + ]); + + const customer = customers.find((c) => c.$id === invoice.customerId); + const settings = ctx.settings; + const logoUrl = getLogoUrl(settings?.logo); + + const statusLabel = STATUS_LABEL[invoice.status ?? "draft"] ?? "Taslak"; + + return ( + <> + + {/* eslint-disable-next-line react/no-unknown-property */} + + +
+ {/* Print / close bar */} + + + {/* Header */} +
+
+ {logoUrl && ( + // eslint-disable-next-line @next/next/no-img-element + Logo + )} +
{settings?.companyName ?? "Şirketiniz"}
+
+ {settings?.companyTaxId && VKN: {settings.companyTaxId}} + {settings?.companyAddress && {settings.companyAddress}} + {settings?.companyPhone && {settings.companyPhone}} + {settings?.companyEmail && {settings.companyEmail}} +
+
+ +
+
FATURA
+
{invoice.number}
+
+ Düzenleme: {formatDate(invoice.issueDate)} + Vade: {formatDate(invoice.dueDate)} +
+ {statusLabel} +
+
+ +
+ + {/* Bill to */} +
+
+
Fatura Kesilecek
+
+ {customer?.name ?? "—"} + {customer?.address && <>
{customer.address}} + {customer?.taxId && ( + <> +
+ VKN: {customer.taxId} + {customer.taxOffice ? ` / ${customer.taxOffice}` : ""} + + )} + {customer?.email && <>
{customer.email}} + {customer?.phone && <>
{customer.phone}} +
+
+
+
Ödeme Bilgisi
+
+ Vade tarihi: {formatDate(invoice.dueDate)} +
+
+
+ + {/* Items */} + + + + + + + + + + + + {items.map((item, i) => ( + + + + + + + + ))} + +
AçıklamaMiktarBirim FiyatKDV %Tutar
{item.description}{item.quantity}{formatTRY(item.unitPrice)}{item.vatRate ?? 0}%{formatTRY(item.lineTotal)}
+ + {/* Totals */} +
+ + + + + + + + + + + + + + + +
Ara toplam{formatTRY(invoice.subtotal ?? 0)}
KDV{formatTRY(invoice.vatTotal ?? 0)}
Genel toplam{formatTRY(invoice.total ?? 0)}
+
+ + {invoice.notes && ( +
+
+
Notlar
+
{invoice.notes}
+
+ )} +
+ + ); +} diff --git a/src/components/attachments-panel.tsx b/src/components/attachments-panel.tsx new file mode 100644 index 0000000..24ec486 --- /dev/null +++ b/src/components/attachments-panel.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useCallback, useActionState, useEffect, useRef, useState, useTransition } from "react"; +import { Loader2, Paperclip, Trash2, Upload } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + deleteAttachmentAction, + fetchAttachmentsAction, + uploadAttachmentAction, + type AttachmentItem, +} from "@/lib/appwrite/attachment-actions"; + +type Props = { + entityType: "service" | "customer_software"; + entityId: string; +}; + +const initialUploadState = { ok: false } as { ok: boolean; error?: string }; + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function AttachmentsPanel({ entityType, entityId }: Props) { + const [attachments, setAttachments] = useState([]); + const [loading, setLoading] = useState(true); + const [uploadState, uploadAction, isUploading] = useActionState( + uploadAttachmentAction, + initialUploadState, + ); + const [, startDelete] = useTransition(); + const fileRef = useRef(null); + + const load = useCallback(async () => { + setLoading(true); + const data = await fetchAttachmentsAction(entityType, entityId); + setAttachments(data); + setLoading(false); + }, [entityType, entityId]); + + useEffect(() => { + load(); + }, [load]); + + useEffect(() => { + if (uploadState.ok) { + toast.success("Dosya yüklendi."); + if (fileRef.current) fileRef.current.value = ""; + load(); + } else if (uploadState.error) { + toast.error(uploadState.error); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [uploadState]); + + const handleDelete = (a: AttachmentItem) => { + startDelete(async () => { + const fd = new FormData(); + fd.set("attachmentId", a.id); + const result = await deleteAttachmentAction(fd); + if (result.ok) { + toast.success("Dosya silindi."); + setAttachments((prev) => prev.filter((x) => x.id !== a.id)); + } else { + toast.error(result.error ?? "Silme başarısız."); + } + }); + }; + + return ( +
+ + +
+ + +
+ + +
+
+ + {loading ? ( +
+ + Yükleniyor... +
+ ) : attachments.length === 0 ? ( +

Henüz ek yok.

+ ) : ( +
+ {attachments.map((a) => ( +
+ + + {a.name} + + + {formatBytes(a.size)} + + +
+ ))} +
+ )} +
+ ); +} diff --git a/src/lib/appwrite/attachment-actions.ts b/src/lib/appwrite/attachment-actions.ts new file mode 100644 index 0000000..72f15df --- /dev/null +++ b/src/lib/appwrite/attachment-actions.ts @@ -0,0 +1,172 @@ +"use server"; + +import { ID, Permission, Query, Role } from "node-appwrite"; +import { InputFile } from "node-appwrite/file"; + +import { BUCKETS, DATABASE_ID, TABLES, type Attachment } from "./schema"; +import { createAdminClient } from "./server"; +import { requireTenant } from "./tenant-guard"; + +export type AttachmentItem = { + id: string; + fileId: string; + name: string; + size: number; + mimeType: string; + createdAt: string; +}; + +type AttachmentActionState = { ok: boolean; error?: string }; + +const MAX_BYTES = 20 * 1024 * 1024; + +function teamFilePermissions(tenantId: string) { + return [ + Permission.read(Role.team(tenantId)), + Permission.update(Role.team(tenantId, "owner")), + Permission.update(Role.team(tenantId, "admin")), + Permission.delete(Role.team(tenantId, "owner")), + Permission.delete(Role.team(tenantId, "admin")), + Permission.delete(Role.team(tenantId, "member")), + ]; +} + +export async function fetchAttachmentsAction( + entityType: string, + entityId: string, +): Promise { + let ctx; + try { + ctx = await requireTenant(); + } catch { + return []; + } + + try { + const { tablesDB } = createAdminClient(); + const result = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.attachments, + queries: [ + Query.equal("tenantId", ctx.tenantId), + Query.equal("entityId", entityId), + Query.orderAsc("$createdAt"), + Query.limit(100), + ], + }); + + return (result.rows as unknown as Attachment[]).map((r) => ({ + id: r.$id, + fileId: r.fileId, + name: r.name, + size: r.size, + mimeType: r.mimeType ?? "", + createdAt: r.$createdAt, + })); + } catch { + return []; + } +} + +export async function uploadAttachmentAction( + _prev: AttachmentActionState, + formData: FormData, +): Promise { + let ctx; + try { + ctx = await requireTenant(); + } catch { + return { ok: false, error: "Yetkiniz yok." }; + } + + const entityType = String(formData.get("entityType") ?? "").trim(); + const entityId = String(formData.get("entityId") ?? "").trim(); + const file = formData.get("file"); + + if (!entityType || !entityId) return { ok: false, error: "Geçersiz istek." }; + if (!(file instanceof File) || file.size === 0) return { ok: false, error: "Dosya seçin." }; + if (file.size > MAX_BYTES) return { ok: false, error: "Dosya 20MB'dan büyük olamaz." }; + + const { storage, tablesDB } = createAdminClient(); + let createdFileId: string | null = null; + + try { + const buffer = Buffer.from(await file.arrayBuffer()); + const inputFile = InputFile.fromBuffer(buffer, file.name); + + const created = await storage.createFile({ + bucketId: BUCKETS.entityAttachments, + fileId: ID.unique(), + file: inputFile, + permissions: teamFilePermissions(ctx.tenantId), + }); + createdFileId = created.$id; + + await tablesDB.createRow( + DATABASE_ID, + TABLES.attachments, + ID.unique(), + { + tenantId: ctx.tenantId, + createdBy: ctx.user.id, + entityType, + entityId, + fileId: createdFileId, + name: file.name, + size: file.size, + mimeType: file.type || null, + }, + teamFilePermissions(ctx.tenantId), + ); + } catch (e) { + if (createdFileId) { + try { + await storage.deleteFile({ bucketId: BUCKETS.entityAttachments, fileId: createdFileId }); + } catch { /* best-effort cleanup */ } + } + return { ok: false, error: e instanceof Error ? e.message : "Yükleme başarısız." }; + } + + return { ok: true }; +} + +export async function deleteAttachmentAction( + formData: FormData, +): Promise { + const attachmentId = String(formData.get("attachmentId") ?? ""); + if (!attachmentId) return { ok: false, error: "ID eksik." }; + + let ctx; + try { + ctx = await requireTenant(); + } catch { + return { ok: false, error: "Yetkiniz yok." }; + } + + const { storage, tablesDB } = createAdminClient(); + + try { + const existing = (await tablesDB.getRow( + DATABASE_ID, + TABLES.attachments, + attachmentId, + )) as unknown as Attachment; + + if (existing.tenantId !== ctx.tenantId) { + return { ok: false, error: "Erişim engellendi." }; + } + + await tablesDB.deleteRow(DATABASE_ID, TABLES.attachments, attachmentId); + + try { + await storage.deleteFile({ + bucketId: BUCKETS.entityAttachments, + fileId: existing.fileId, + }); + } catch { /* best-effort — DB row already deleted */ } + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : "Silme başarısız." }; + } + + return { ok: true }; +} diff --git a/src/lib/appwrite/schema.ts b/src/lib/appwrite/schema.ts index 79d3387..eef4010 100644 --- a/src/lib/appwrite/schema.ts +++ b/src/lib/appwrite/schema.ts @@ -2,6 +2,7 @@ export const DATABASE_ID = "isletmem"; export const BUCKETS = { tenantLogos: "tenant-logos", + entityAttachments: "entity-attachments", } as const; export const TABLES = { @@ -27,6 +28,7 @@ export const TABLES = { leads: "leads", leadActivities: "lead_activities", passwordResets: "password_resets", + attachments: "attachments", } as const; export type TableId = (typeof TABLES)[keyof typeof TABLES]; @@ -361,3 +363,14 @@ export interface InviteLink extends Row { acceptedAt?: string; acceptedBy?: string; } + +export interface Attachment extends Row { + tenantId: string; + createdBy: string; + entityType: string; + entityId: string; + fileId: string; + name: string; + size: number; + mimeType?: string; +}