feat: fatura PDF, hizmet/yazılım atama dosya ekleri
- /print/invoices/[id] sayfası: A4 fatura yazdırma/PDF (AutoPrint + PrintActionBar) - Fatura detayı header'ına PDF butonu eklendi (Yazdır yerine) - Appwrite Storage: entity-attachments bucket (20MB, şifreli) - Appwrite Tables: attachments collection (tenantId, entityType, entityId, fileId, name, size, mimeType) - attachment-actions.ts: fetchAttachmentsAction, uploadAttachmentAction, deleteAttachmentAction - AttachmentsPanel bileşeni: dosya yükleme/listeleme/silme, edit modunda görünür - Hizmet ve yazılım atama form sheet'lerine AttachmentsPanel entegrasyonu - /api/files/[attachmentId]: güvenli proxy indirme (tenant doğrulama + admin key ile Appwrite'a istek)
This commit is contained in:
@@ -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 (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => window.print()}>
|
||||
<Printer className="size-3.5" />
|
||||
Yazdır
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(`/print/invoices/${invoice.id}`, "_blank")}
|
||||
>
|
||||
<FileDown className="size-3.5" />
|
||||
PDF
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
|
||||
<Pencil className="size-3.5" />
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
updateServiceAction,
|
||||
} from "@/lib/appwrite/service-actions";
|
||||
import { initialServiceState } from "@/lib/appwrite/service-types";
|
||||
import { AttachmentsPanel } from "@/components/attachments-panel";
|
||||
import type { CustomerOption, MemberOption, ServiceRow } from "./types";
|
||||
|
||||
const PRESET_SERVICES = [
|
||||
@@ -262,6 +263,11 @@ export function ServiceFormSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ekler */}
|
||||
{isEdit && service && (
|
||||
<AttachmentsPanel entityType="service" entityId={service.id} />
|
||||
)}
|
||||
|
||||
{/* Sorumlu personel */}
|
||||
{members.length > 0 && (
|
||||
<div className="grid gap-2">
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ekler */}
|
||||
{isEdit && assignment && (
|
||||
<AttachmentsPanel
|
||||
entityType="customer_software"
|
||||
entityId={assignment.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className="no-print"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: "0.75rem",
|
||||
marginBottom: "1.5rem",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<>
|
||||
<AutoPrint />
|
||||
{/* eslint-disable-next-line react/no-unknown-property */}
|
||||
<style>{`
|
||||
@media print {
|
||||
@page { margin: 1.5cm; size: A4 portrait; }
|
||||
.no-print { display: none !important; }
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body {
|
||||
background: #ffffff;
|
||||
color: #111827;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.page { max-width: 860px; margin: 0 auto; padding: 2.5rem 3rem; }
|
||||
.header { display: flex; justify-content: space-between; align-items: flex-start; gap: 2rem; margin-bottom: 2rem; }
|
||||
.company-block { flex: 1; }
|
||||
.company-logo { max-height: 56px; max-width: 160px; object-fit: contain; margin-bottom: 0.75rem; }
|
||||
.company-name { font-size: 1.1rem; font-weight: 700; color: #111827; }
|
||||
.company-meta { font-size: 0.8rem; color: #6b7280; margin-top: 0.25rem; line-height: 1.5; }
|
||||
.invoice-title-block { text-align: right; }
|
||||
.invoice-title { font-size: 2rem; font-weight: 800; letter-spacing: -0.02em; color: #111827; }
|
||||
.invoice-number { font-size: 1rem; font-weight: 600; font-family: "SF Mono", "Fira Code", monospace; color: #374151; margin-top: 0.25rem; }
|
||||
.invoice-meta { font-size: 0.8rem; color: #6b7280; margin-top: 0.5rem; display: grid; row-gap: 0.15rem; }
|
||||
.invoice-meta span { display: block; }
|
||||
.status-badge { display: inline-block; margin-top: 0.4rem; padding: 0.2rem 0.65rem; border-radius: 9999px; font-size: 0.7rem; font-weight: 600; letter-spacing: 0.03em; text-transform: uppercase; background: #f3f4f6; color: #374151; border: 1px solid #e5e7eb; }
|
||||
.divider { border: none; border-top: 1.5px solid #e5e7eb; margin: 1.5rem 0; }
|
||||
.bill-section { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; margin-bottom: 1.75rem; }
|
||||
.bill-block-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: #9ca3af; margin-bottom: 0.35rem; }
|
||||
.bill-block-value { font-size: 0.85rem; color: #111827; line-height: 1.55; }
|
||||
.bill-block-value strong { font-weight: 700; font-size: 0.95rem; }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 1.5rem; }
|
||||
thead tr { border-bottom: 2px solid #111827; }
|
||||
thead th { padding: 0.4rem 0.5rem; font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: #374151; text-align: left; }
|
||||
thead th.num { text-align: right; }
|
||||
tbody tr { border-bottom: 1px solid #f3f4f6; }
|
||||
tbody tr:last-child { border-bottom: 1px solid #e5e7eb; }
|
||||
tbody td { padding: 0.55rem 0.5rem; font-size: 0.85rem; color: #374151; vertical-align: top; }
|
||||
tbody td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.totals { display: flex; justify-content: flex-end; }
|
||||
.totals-table { width: 280px; }
|
||||
.totals-table tr td { padding: 0.3rem 0.5rem; font-size: 0.85rem; }
|
||||
.totals-table tr td:first-child { color: #6b7280; }
|
||||
.totals-table tr td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.totals-total { border-top: 2px solid #111827; }
|
||||
.totals-total td { padding-top: 0.5rem !important; font-size: 1rem; font-weight: 700; color: #111827 !important; }
|
||||
.notes-section { margin-top: 1.75rem; }
|
||||
.notes-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: #9ca3af; margin-bottom: 0.4rem; }
|
||||
.notes-text { font-size: 0.85rem; color: #374151; white-space: pre-line; }
|
||||
.print-btn-bar { display: flex; justify-content: flex-end; gap: 0.75rem; margin-bottom: 1.5rem; }
|
||||
.print-btn { cursor: pointer; padding: 0.5rem 1.25rem; border-radius: 6px; font-size: 0.85rem; font-weight: 500; border: 1px solid #d1d5db; background: #f9fafb; color: #374151; }
|
||||
.print-btn:hover { background: #f3f4f6; }
|
||||
`}</style>
|
||||
|
||||
<div className="page">
|
||||
{/* Print / close bar */}
|
||||
<PrintActionBar />
|
||||
|
||||
{/* Header */}
|
||||
<div className="header">
|
||||
<div className="company-block">
|
||||
{logoUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={logoUrl} alt="Logo" className="company-logo" />
|
||||
)}
|
||||
<div className="company-name">{settings?.companyName ?? "Şirketiniz"}</div>
|
||||
<div className="company-meta">
|
||||
{settings?.companyTaxId && <span>VKN: {settings.companyTaxId}</span>}
|
||||
{settings?.companyAddress && <span>{settings.companyAddress}</span>}
|
||||
{settings?.companyPhone && <span>{settings.companyPhone}</span>}
|
||||
{settings?.companyEmail && <span>{settings.companyEmail}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="invoice-title-block">
|
||||
<div className="invoice-title">FATURA</div>
|
||||
<div className="invoice-number">{invoice.number}</div>
|
||||
<div className="invoice-meta">
|
||||
<span>Düzenleme: {formatDate(invoice.issueDate)}</span>
|
||||
<span>Vade: {formatDate(invoice.dueDate)}</span>
|
||||
</div>
|
||||
<span className="status-badge">{statusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="divider" />
|
||||
|
||||
{/* Bill to */}
|
||||
<div className="bill-section">
|
||||
<div>
|
||||
<div className="bill-block-label">Fatura Kesilecek</div>
|
||||
<div className="bill-block-value">
|
||||
<strong>{customer?.name ?? "—"}</strong>
|
||||
{customer?.address && <><br />{customer.address}</>}
|
||||
{customer?.taxId && (
|
||||
<>
|
||||
<br />
|
||||
VKN: {customer.taxId}
|
||||
{customer.taxOffice ? ` / ${customer.taxOffice}` : ""}
|
||||
</>
|
||||
)}
|
||||
{customer?.email && <><br />{customer.email}</>}
|
||||
{customer?.phone && <><br />{customer.phone}</>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="bill-block-label">Ödeme Bilgisi</div>
|
||||
<div className="bill-block-value">
|
||||
<span>Vade tarihi: {formatDate(invoice.dueDate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: "40%" }}>Açıklama</th>
|
||||
<th className="num" style={{ width: "10%" }}>Miktar</th>
|
||||
<th className="num" style={{ width: "18%" }}>Birim Fiyat</th>
|
||||
<th className="num" style={{ width: "10%" }}>KDV %</th>
|
||||
<th className="num" style={{ width: "22%" }}>Tutar</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, i) => (
|
||||
<tr key={item.$id ?? i}>
|
||||
<td>{item.description}</td>
|
||||
<td className="num">{item.quantity}</td>
|
||||
<td className="num">{formatTRY(item.unitPrice)}</td>
|
||||
<td className="num">{item.vatRate ?? 0}%</td>
|
||||
<td className="num">{formatTRY(item.lineTotal)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Totals */}
|
||||
<div className="totals">
|
||||
<table className="totals-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Ara toplam</td>
|
||||
<td>{formatTRY(invoice.subtotal ?? 0)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>KDV</td>
|
||||
<td>{formatTRY(invoice.vatTotal ?? 0)}</td>
|
||||
</tr>
|
||||
<tr className="totals-total">
|
||||
<td>Genel toplam</td>
|
||||
<td>{formatTRY(invoice.total ?? 0)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{invoice.notes && (
|
||||
<div className="notes-section">
|
||||
<hr className="divider" />
|
||||
<div className="notes-label">Notlar</div>
|
||||
<div className="notes-text">{invoice.notes}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<AttachmentItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploadState, uploadAction, isUploading] = useActionState(
|
||||
uploadAttachmentAction,
|
||||
initialUploadState,
|
||||
);
|
||||
const [, startDelete] = useTransition();
|
||||
const fileRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="grid gap-2">
|
||||
<Label>Ekler</Label>
|
||||
|
||||
<form action={uploadAction}>
|
||||
<input type="hidden" name="entityType" value={entityType} />
|
||||
<input type="hidden" name="entityId" value={entityId} />
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
name="file"
|
||||
className="flex-1 text-sm file:mr-2 file:cursor-pointer file:rounded file:border-0 file:bg-muted file:px-3 file:py-1.5 file:text-xs file:font-medium"
|
||||
/>
|
||||
<Button type="submit" variant="outline" size="sm" disabled={isUploading}>
|
||||
{isUploading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="size-4" />
|
||||
)}
|
||||
Yükle
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 py-1 text-xs">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Yükleniyor...
|
||||
</div>
|
||||
) : attachments.length === 0 ? (
|
||||
<p className="text-muted-foreground py-1 text-xs">Henüz ek yok.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{attachments.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="flex items-center gap-2 rounded border px-2.5 py-1.5"
|
||||
>
|
||||
<Paperclip className="text-muted-foreground size-3.5 shrink-0" />
|
||||
<a
|
||||
href={`/api/files/${a.id}`}
|
||||
download={a.name}
|
||||
className="min-w-0 flex-1 truncate text-xs hover:underline"
|
||||
title={a.name}
|
||||
>
|
||||
{a.name}
|
||||
</a>
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{formatBytes(a.size)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(a)}
|
||||
className="text-muted-foreground hover:text-destructive ml-1 shrink-0"
|
||||
title="Sil"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<AttachmentItem[]> {
|
||||
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<AttachmentActionState> {
|
||||
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<AttachmentActionState> {
|
||||
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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user