Move business logic out of App Router into module packages, add boundary validation scripts, and keep all routes as thin re-exports without changing URLs or API behavior. Co-authored-by: Cursor <cursoragent@cursor.com>
289 lines
10 KiB
TypeScript
289 lines
10 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { Calendar, MapPin, Scissors, User, Building2, Sparkles, Gift, Clock } from "lucide-react";
|
||
import { Card, CardContent, Badge } from "@/components/ds";
|
||
import { cn } from "@/lib/utils";
|
||
import { BeautyStatusChip } from "./BeautyStatusChip";
|
||
import { businessFormatLabels } from "./tokens";
|
||
import type {
|
||
Appointment,
|
||
Branch,
|
||
BeautyService,
|
||
CustomerProfile,
|
||
Organization,
|
||
PackageDefinition,
|
||
StaffProfile,
|
||
} from "@/modules/beauty/services/beauty-business-api";
|
||
|
||
function CardShell({
|
||
children,
|
||
href,
|
||
className,
|
||
}: {
|
||
children: React.ReactNode;
|
||
href?: string;
|
||
className?: string;
|
||
}) {
|
||
const inner = (
|
||
<Card
|
||
className={cn(
|
||
"group border-[var(--border)] bg-[var(--surface)] shadow-[var(--shadow-sm)] transition-all duration-200 hover:shadow-[var(--shadow-md)]",
|
||
href && "cursor-pointer hover:border-[var(--beauty-accent)]/30",
|
||
className
|
||
)}
|
||
>
|
||
<CardContent className="p-5">{children}</CardContent>
|
||
</Card>
|
||
);
|
||
if (href) return <Link href={href}>{inner}</Link>;
|
||
return inner;
|
||
}
|
||
|
||
export function CenterCard({ org, href }: { org: Organization; href?: string }) {
|
||
return (
|
||
<CardShell href={href ?? `/beauty/site/center/${org.id}`}>
|
||
<div className="flex items-start gap-4">
|
||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-[var(--beauty-accent-soft)] text-[var(--beauty-accent)]">
|
||
<Building2 className="h-6 w-6" />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-lg font-semibold text-secondary">{org.name}</p>
|
||
<p className="mt-1 text-sm text-[var(--muted)]">{org.code}</p>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
<Badge tone="default">{businessFormatLabels[org.business_format] ?? org.business_format}</Badge>
|
||
<BeautyStatusChip status={org.status} domain="lifecycle" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardShell>
|
||
);
|
||
}
|
||
|
||
export function SalonCard({ branch, href }: { branch: Branch; href?: string }) {
|
||
return (
|
||
<CardShell href={href ?? `/beauty/site/salon/${branch.id}`}>
|
||
<div className="flex items-start gap-4">
|
||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-[var(--beauty-warm)]/15 text-[var(--beauty-accent)]">
|
||
<Sparkles className="h-6 w-6" />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-lg font-semibold text-secondary">{branch.name}</p>
|
||
<p className="mt-1 text-sm text-[var(--muted)]">{branch.code}</p>
|
||
<BeautyStatusChip status={branch.status} domain="lifecycle" className="mt-3" />
|
||
</div>
|
||
</div>
|
||
</CardShell>
|
||
);
|
||
}
|
||
|
||
export function ServiceCard({ service, href }: { service: BeautyService; href?: string }) {
|
||
return (
|
||
<CardShell href={href ?? `/beauty/site/service/${service.id}`}>
|
||
<div className="flex items-start gap-4">
|
||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-[var(--beauty-calm)]/20 text-[var(--beauty-accent)]">
|
||
<Scissors className="h-6 w-6" />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-lg font-semibold text-secondary">{service.name}</p>
|
||
<p className="mt-1 text-sm text-[var(--muted)]">
|
||
{service.base_duration_minutes} دقیقه
|
||
{service.base_price != null ? ` · ${service.base_price.toLocaleString("fa-IR")}` : ""}
|
||
</p>
|
||
<BeautyStatusChip status={service.status} className="mt-3" />
|
||
</div>
|
||
</div>
|
||
</CardShell>
|
||
);
|
||
}
|
||
|
||
export function StaffCard({ staff, href }: { staff: StaffProfile; href?: string }) {
|
||
return (
|
||
<CardShell href={href ?? `/beauty/site/staff/${staff.id}`}>
|
||
<div className="flex items-start gap-4">
|
||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-[var(--surface-muted)] text-secondary">
|
||
<User className="h-6 w-6" />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-lg font-semibold text-secondary">{staff.display_name}</p>
|
||
<p className="mt-1 text-sm text-[var(--muted)]">{staff.kind}</p>
|
||
<BeautyStatusChip status={staff.status} domain="lifecycle" className="mt-3" />
|
||
</div>
|
||
</div>
|
||
</CardShell>
|
||
);
|
||
}
|
||
|
||
export function AppointmentCard({
|
||
appointment,
|
||
staffName,
|
||
serviceName,
|
||
href,
|
||
actions,
|
||
}: {
|
||
appointment: Appointment;
|
||
staffName?: string;
|
||
serviceName?: string;
|
||
href?: string;
|
||
actions?: React.ReactNode;
|
||
}) {
|
||
const start = new Date(appointment.scheduled_start);
|
||
return (
|
||
<CardShell href={href} className="relative">
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<BeautyStatusChip status={appointment.status} domain="appointment" />
|
||
<span className="text-xs text-[var(--muted)]">{appointment.code}</span>
|
||
</div>
|
||
<p className="mt-2 flex items-center gap-2 text-sm font-medium text-secondary">
|
||
<Calendar className="h-4 w-4 text-[var(--beauty-accent)]" />
|
||
{start.toLocaleString("fa-IR")}
|
||
</p>
|
||
{staffName ? (
|
||
<p className="mt-1 flex items-center gap-2 text-sm text-[var(--muted)]">
|
||
<User className="h-4 w-4" />
|
||
{staffName}
|
||
</p>
|
||
) : null}
|
||
{serviceName ? (
|
||
<p className="mt-1 flex items-center gap-2 text-sm text-[var(--muted)]">
|
||
<Scissors className="h-4 w-4" />
|
||
{serviceName}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
{actions ? <div className="flex shrink-0 flex-wrap gap-2">{actions}</div> : null}
|
||
</div>
|
||
</CardShell>
|
||
);
|
||
}
|
||
|
||
export function PackageCard({ pkg, href }: { pkg: PackageDefinition; href?: string }) {
|
||
return (
|
||
<CardShell href={href}>
|
||
<div className="flex items-start gap-4">
|
||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-[var(--beauty-accent-soft)] text-[var(--beauty-accent)]">
|
||
<Gift className="h-6 w-6" />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-lg font-semibold text-secondary">{pkg.name}</p>
|
||
<p className="mt-1 text-sm text-[var(--muted)]">
|
||
{pkg.total_sessions} جلسه
|
||
{pkg.price != null ? ` · ${pkg.price.toLocaleString("fa-IR")}` : ""}
|
||
</p>
|
||
<BeautyStatusChip status={pkg.status} domain="lifecycle" className="mt-3" />
|
||
</div>
|
||
</div>
|
||
</CardShell>
|
||
);
|
||
}
|
||
|
||
export function CustomerCard({ customer, href }: { customer: CustomerProfile; href?: string }) {
|
||
return (
|
||
<CardShell href={href}>
|
||
<div className="flex items-start gap-4">
|
||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-[var(--surface-muted)] text-secondary">
|
||
<User className="h-6 w-6" />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-lg font-semibold text-secondary">{customer.display_name}</p>
|
||
<p className="mt-1 text-sm text-[var(--muted)]">{customer.mobile ?? customer.code}</p>
|
||
<BeautyStatusChip status={customer.status} domain="lifecycle" className="mt-3" />
|
||
</div>
|
||
</div>
|
||
</CardShell>
|
||
);
|
||
}
|
||
|
||
export function QueueRow({
|
||
position,
|
||
title,
|
||
subtitle,
|
||
status,
|
||
actions,
|
||
}: {
|
||
position: number;
|
||
title: string;
|
||
subtitle?: string;
|
||
status: string;
|
||
actions?: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="flex items-center gap-4 rounded-2xl border border-[var(--border)] bg-[var(--surface)] px-4 py-3 shadow-[var(--shadow-sm)]">
|
||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[var(--beauty-accent-soft)] text-sm font-bold text-[var(--beauty-accent)]">
|
||
{position}
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="font-medium text-secondary">{title}</p>
|
||
{subtitle ? <p className="text-sm text-[var(--muted)]">{subtitle}</p> : null}
|
||
</div>
|
||
<BeautyStatusChip status={status} />
|
||
{actions}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function SearchResultRow({
|
||
title,
|
||
meta,
|
||
href,
|
||
}: {
|
||
title: string;
|
||
meta: string;
|
||
href: string;
|
||
}) {
|
||
return (
|
||
<Link
|
||
href={href}
|
||
className="flex items-center justify-between gap-4 rounded-2xl border border-[var(--border)] bg-[var(--surface)] px-4 py-4 transition-colors hover:bg-[var(--surface-muted)]"
|
||
>
|
||
<div>
|
||
<p className="font-medium text-secondary">{title}</p>
|
||
<p className="mt-1 flex items-center gap-1 text-sm text-[var(--muted)]">
|
||
<MapPin className="h-3.5 w-3.5" />
|
||
{meta}
|
||
</p>
|
||
</div>
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
export function TimelineItem({
|
||
title,
|
||
subtitle,
|
||
time,
|
||
active,
|
||
}: {
|
||
title: string;
|
||
subtitle?: string;
|
||
time?: string;
|
||
active?: boolean;
|
||
}) {
|
||
return (
|
||
<div className="relative flex gap-4 pb-6 last:pb-0">
|
||
<div className="flex flex-col items-center">
|
||
<div
|
||
className={cn(
|
||
"h-3 w-3 rounded-full ring-4",
|
||
active
|
||
? "bg-[var(--beauty-accent)] ring-[var(--beauty-accent-soft)]"
|
||
: "bg-[var(--border)] ring-[var(--surface-muted)]"
|
||
)}
|
||
/>
|
||
<div className="mt-1 w-px flex-1 bg-[var(--border)]" />
|
||
</div>
|
||
<div className="min-w-0 flex-1 pt-0.5">
|
||
<p className="font-medium text-secondary">{title}</p>
|
||
{subtitle ? <p className="mt-0.5 text-sm text-[var(--muted)]">{subtitle}</p> : null}
|
||
{time ? (
|
||
<p className="mt-1 flex items-center gap-1 text-xs text-[var(--muted)]">
|
||
<Clock className="h-3 w-3" />
|
||
{time}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|