196036c0d8
Plan & billing layer:
- New tables: subscription_payments, saved_cards (via Appwrite MCP)
- tenant_settings: plan/planStartedAt/planExpiresAt/lastPaymentId columns
- Free tier limits (50 customers / 100 finance entries / 5 software / 1 member)
enforced via requirePlanCapacity gate in create actions
- PlanLimitDialog opens when limit hit; UsageBanner at 80% threshold
- /pricing rebuilt with Free + Pro tiers and Klinik/Ajans ecosystem teasers
- /settings/billing redesigned: compact plan summary, saved cards list,
KVKK transparency block, payment history
- Usage stats moved to /pricing where they are decision-relevant
Mock checkout flow:
- 3D animated credit card with sync inputs and CVC flip
- Brand auto-detection (Visa / Mastercard / Amex / troy)
- Saved-card mode when previous cards exist; first card defaults to default
- 'Bu kartı kaydet' checkbox with explicit storage scope disclosure
- /settings/billing/checkout/[orderId] route
Saved cards:
- saved_cards bucket stores last4 + brand + expiry + holder only
- Default toggle, remove action, owner-only management
- Architecture ready for Shopier provider token swap-in
Tenant logo upload (first file upload feature):
- New Appwrite bucket: tenant-logos (max 2MB, image only, public read)
- uploadLogoAction with orphan cleanup, removeLogoAction
- LogoUploader UI: drag-drop, client-side preview, validation
- Sidebar shows logo when set, falls back to default icon
Mobile sheet fix:
- SheetContent uses h-dvh instead of h-full (dynamic viewport)
- SheetFooter pads pb-[max(1rem,env(safe-area-inset-bottom))]
- 13 form sheets switched py-4 → pt-4 to let safe-area apply
db: subscription_payments, saved_cards tables; tenant_settings plan columns;
tenant-logos storage bucket
205 lines
6.9 KiB
TypeScript
205 lines
6.9 KiB
TypeScript
"use client";
|
||
|
||
import { useActionState, useEffect } from "react";
|
||
import { Loader2, Save } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import {
|
||
Sheet,
|
||
SheetContent,
|
||
SheetDescription,
|
||
SheetFooter,
|
||
SheetHeader,
|
||
SheetTitle,
|
||
} from "@/components/ui/sheet";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import {
|
||
createServiceAction,
|
||
updateServiceAction,
|
||
} from "@/lib/appwrite/service-actions";
|
||
import { initialServiceState } from "@/lib/appwrite/service-types";
|
||
import type { CustomerOption, ServiceRow } from "./types";
|
||
|
||
type Props = {
|
||
open: boolean;
|
||
onOpenChange: (v: boolean) => void;
|
||
service?: ServiceRow | null;
|
||
customers: CustomerOption[];
|
||
defaultCustomerId?: string;
|
||
};
|
||
|
||
export function ServiceFormSheet({
|
||
open,
|
||
onOpenChange,
|
||
service,
|
||
customers,
|
||
defaultCustomerId,
|
||
}: Props) {
|
||
const isEdit = Boolean(service);
|
||
const action = isEdit ? updateServiceAction : createServiceAction;
|
||
const [state, formAction, isPending] = useActionState(action, initialServiceState);
|
||
|
||
useEffect(() => {
|
||
if (state.ok) {
|
||
toast.success(isEdit ? "Hizmet güncellendi." : "Hizmet eklendi.");
|
||
onOpenChange(false);
|
||
} else if (state.error) {
|
||
toast.error(state.error);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [state]);
|
||
|
||
return (
|
||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
|
||
<SheetHeader className="border-b px-6 py-4">
|
||
<SheetTitle>{isEdit ? "Hizmeti düzenle" : "Yeni hizmet"}</SheetTitle>
|
||
<SheetDescription>
|
||
{customers.length === 0
|
||
? "Hizmet eklemek için önce en az bir müşteri tanımlamalısınız."
|
||
: "Müşteriye sunduğunuz hizmeti tanımlayın."}
|
||
</SheetDescription>
|
||
</SheetHeader>
|
||
|
||
<form action={formAction} className="flex flex-1 flex-col">
|
||
{isEdit && service && <input type="hidden" name="id" value={service.id} />}
|
||
|
||
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="customerId">Müşteri *</Label>
|
||
<Select
|
||
name="customerId"
|
||
defaultValue={service?.customerId ?? defaultCustomerId ?? ""}
|
||
disabled={customers.length === 0}
|
||
>
|
||
<SelectTrigger id="customerId">
|
||
<SelectValue placeholder="Müşteri seçin" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{customers.map((c) => (
|
||
<SelectItem key={c.id} value={c.id}>
|
||
{c.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
{state.fieldErrors?.customerId && (
|
||
<p className="text-destructive text-xs">{state.fieldErrors.customerId}</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="name">Hizmet adı *</Label>
|
||
<Input
|
||
id="name"
|
||
name="name"
|
||
defaultValue={service?.name ?? ""}
|
||
placeholder="Örn. Web hosting bakımı"
|
||
required
|
||
/>
|
||
{state.fieldErrors?.name && (
|
||
<p className="text-destructive text-xs">{state.fieldErrors.name}</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="description">Açıklama</Label>
|
||
<Textarea
|
||
id="description"
|
||
name="description"
|
||
rows={3}
|
||
defaultValue={service?.description ?? ""}
|
||
placeholder="Hizmetin kapsamı, sınırları, vb."
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="unitPrice">Birim fiyat (₺) *</Label>
|
||
<Input
|
||
id="unitPrice"
|
||
name="unitPrice"
|
||
type="number"
|
||
step="0.01"
|
||
min="0"
|
||
defaultValue={service?.unitPrice ?? ""}
|
||
placeholder="0.00"
|
||
required
|
||
/>
|
||
{state.fieldErrors?.unitPrice && (
|
||
<p className="text-destructive text-xs">{state.fieldErrors.unitPrice}</p>
|
||
)}
|
||
</div>
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="billingPeriod">Faturalama dönemi</Label>
|
||
<Select
|
||
name="billingPeriod"
|
||
defaultValue={service?.billingPeriod ?? "onetime"}
|
||
>
|
||
<SelectTrigger id="billingPeriod">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="onetime">Tek seferlik</SelectItem>
|
||
<SelectItem value="monthly">Aylık</SelectItem>
|
||
<SelectItem value="yearly">Yıllık</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between rounded-md border p-3">
|
||
<div className="grid gap-0.5">
|
||
<Label htmlFor="recurring" className="cursor-pointer">
|
||
Tekrarlayan hizmet
|
||
</Label>
|
||
<p className="text-muted-foreground text-xs">
|
||
Bu hizmet düzenli olarak fatura kesilecek mi?
|
||
</p>
|
||
</div>
|
||
<Switch id="recurring" name="recurring" defaultChecked={service?.recurring} />
|
||
</div>
|
||
</div>
|
||
|
||
<SheetFooter className="border-t bg-muted/30 px-6 pt-4">
|
||
<div className="flex w-full justify-end gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => onOpenChange(false)}
|
||
disabled={isPending}
|
||
>
|
||
Vazgeç
|
||
</Button>
|
||
<Button type="submit" disabled={isPending || customers.length === 0}>
|
||
{isPending ? (
|
||
<>
|
||
<Loader2 className="size-4 animate-spin" />
|
||
Kaydediliyor...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Save className="size-4" />
|
||
{isEdit ? "Güncelle" : "Kaydet"}
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</SheetFooter>
|
||
</form>
|
||
</SheetContent>
|
||
</Sheet>
|
||
);
|
||
}
|