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 (
+
+ window.close()}
+ style={{
+ cursor: "pointer",
+ padding: "0.5rem 1.25rem",
+ borderRadius: "6px",
+ fontSize: "0.85rem",
+ fontWeight: 500,
+ border: "1px solid #d1d5db",
+ background: "#f9fafb",
+ color: "#374151",
+ }}
+ >
+ Kapat
+
+ window.print()}
+ style={{
+ cursor: "pointer",
+ padding: "0.5rem 1.25rem",
+ borderRadius: "6px",
+ fontSize: "0.85rem",
+ fontWeight: 500,
+ border: "1px solid #111827",
+ background: "#111827",
+ color: "#fff",
+ }}
+ >
+ Yazdır / PDF
+
+
+ );
+}
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
+

+ )}
+
{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 */}
+
+
+
+ | Açıklama |
+ Miktar |
+ Birim Fiyat |
+ KDV % |
+ Tutar |
+
+
+
+ {items.map((item, i) => (
+
+ | {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)}
+
+
handleDelete(a)}
+ className="text-muted-foreground hover:text-destructive ml-1 shrink-0"
+ title="Sil"
+ >
+
+
+
+ ))}
+
+ )}
+
+ );
+}
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;
+}