@pylonsync/create-pylon 0.3.274 → 0.3.276
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-pylon.js +80 -0
- package/package.json +1 -1
- package/templates/ARCHETYPES.md +339 -0
- package/templates/agency/.env.example +12 -0
- package/templates/agency/AGENTS.md +61 -0
- package/templates/agency/README.md +90 -0
- package/templates/agency/app/auth-form.tsx +129 -0
- package/templates/agency/app/contact-form.tsx +258 -0
- package/templates/agency/app/dashboard/dashboard-client.tsx +1440 -0
- package/templates/agency/app/dashboard/page.tsx +70 -0
- package/templates/agency/app/error.tsx +26 -0
- package/templates/agency/app/globals.css +148 -0
- package/templates/agency/app/layout.tsx +174 -0
- package/templates/agency/app/login/page.tsx +39 -0
- package/templates/agency/app/not-found.tsx +19 -0
- package/templates/agency/app/page.tsx +249 -0
- package/templates/agency/app/robots.ts +12 -0
- package/templates/agency/app/seeder.tsx +26 -0
- package/templates/agency/app/sitemap.ts +9 -0
- package/templates/agency/app/work/[slug]/page.tsx +182 -0
- package/templates/agency/app/work/page.tsx +83 -0
- package/templates/agency/app.ts +284 -0
- package/templates/agency/components/marketing.tsx +187 -0
- package/templates/agency/components/section-scroller.tsx +35 -0
- package/templates/agency/components/ui/button.tsx +56 -0
- package/templates/agency/components/ui/card.tsx +90 -0
- package/templates/agency/components.json +20 -0
- package/templates/agency/functions/bookInquiry.ts +42 -0
- package/templates/agency/functions/clientsForOwner.ts +27 -0
- package/templates/agency/functions/declineInquiry.ts +41 -0
- package/templates/agency/functions/deleteClient.ts +27 -0
- package/templates/agency/functions/deleteInvoice.ts +19 -0
- package/templates/agency/functions/deleteProject.ts +20 -0
- package/templates/agency/functions/inquiriesForOwner.ts +31 -0
- package/templates/agency/functions/invoicesForOwner.ts +27 -0
- package/templates/agency/functions/seedCapacity.ts +26 -0
- package/templates/agency/functions/seedProjects.ts +41 -0
- package/templates/agency/functions/seedStudioBackoffice.ts +74 -0
- package/templates/agency/functions/setCapacity.ts +32 -0
- package/templates/agency/functions/setInvoiceStatus.ts +27 -0
- package/templates/agency/functions/setProjectFlags.ts +35 -0
- package/templates/agency/functions/submitInquiry.ts +55 -0
- package/templates/agency/functions/upsertClient.ts +73 -0
- package/templates/agency/functions/upsertInvoice.ts +113 -0
- package/templates/agency/functions/upsertProject.ts +97 -0
- package/templates/agency/gitignore +10 -0
- package/templates/agency/lib/agency.ts +189 -0
- package/templates/agency/lib/invoice-pdf.tsx +174 -0
- package/templates/agency/lib/owner.ts +26 -0
- package/templates/agency/lib/site.config.ts +418 -0
- package/templates/agency/lib/utils.ts +10 -0
- package/templates/agency/package.json +35 -0
- package/templates/agency/tsconfig.json +18 -0
- package/templates/ai-chat/.env.example +33 -0
- package/templates/ai-chat/AGENTS.md +61 -0
- package/templates/ai-chat/README.md +99 -0
- package/templates/ai-chat/app/auth-form.tsx +124 -0
- package/templates/ai-chat/app/chat-client.tsx +727 -0
- package/templates/ai-chat/app/error.tsx +26 -0
- package/templates/ai-chat/app/globals.css +148 -0
- package/templates/ai-chat/app/layout.tsx +75 -0
- package/templates/ai-chat/app/login/page.tsx +39 -0
- package/templates/ai-chat/app/not-found.tsx +19 -0
- package/templates/ai-chat/app/page.tsx +23 -0
- package/templates/ai-chat/app.ts +121 -0
- package/templates/ai-chat/components.json +20 -0
- package/templates/ai-chat/functions/deleteConversation.ts +33 -0
- package/templates/ai-chat/gitignore +10 -0
- package/templates/ai-chat/lib/site.config.ts +103 -0
- package/templates/ai-chat/lib/utils.ts +10 -0
- package/templates/ai-chat/package.json +34 -0
- package/templates/ai-chat/tsconfig.json +18 -0
- package/templates/ai-studio/.env.example +19 -0
- package/templates/ai-studio/AGENTS.md +61 -0
- package/templates/ai-studio/README.md +83 -0
- package/templates/ai-studio/app/auth-form.tsx +124 -0
- package/templates/ai-studio/app/error.tsx +26 -0
- package/templates/ai-studio/app/globals.css +148 -0
- package/templates/ai-studio/app/layout.tsx +75 -0
- package/templates/ai-studio/app/login/page.tsx +39 -0
- package/templates/ai-studio/app/not-found.tsx +19 -0
- package/templates/ai-studio/app/page.tsx +34 -0
- package/templates/ai-studio/app/studio-client.tsx +357 -0
- package/templates/ai-studio/app.ts +108 -0
- package/templates/ai-studio/components.json +20 -0
- package/templates/ai-studio/functions/_getGeneration.ts +25 -0
- package/templates/ai-studio/functions/_updateGeneration.ts +37 -0
- package/templates/ai-studio/functions/generate.ts +42 -0
- package/templates/ai-studio/functions/pollGeneration.ts +134 -0
- package/templates/ai-studio/gitignore +10 -0
- package/templates/ai-studio/lib/site.config.ts +80 -0
- package/templates/ai-studio/lib/studio.ts +52 -0
- package/templates/ai-studio/lib/utils.ts +10 -0
- package/templates/ai-studio/package.json +34 -0
- package/templates/ai-studio/tsconfig.json +18 -0
- package/templates/creator/.env.example +12 -0
- package/templates/creator/AGENTS.md +61 -0
- package/templates/creator/README.md +67 -0
- package/templates/creator/app/auth-form.tsx +129 -0
- package/templates/creator/app/dashboard/dashboard-client.tsx +297 -0
- package/templates/creator/app/dashboard/page.tsx +70 -0
- package/templates/creator/app/error.tsx +26 -0
- package/templates/creator/app/globals.css +148 -0
- package/templates/creator/app/layout.tsx +160 -0
- package/templates/creator/app/login/page.tsx +39 -0
- package/templates/creator/app/newsletter-signup.tsx +162 -0
- package/templates/creator/app/not-found.tsx +19 -0
- package/templates/creator/app/page.tsx +160 -0
- package/templates/creator/app/robots.ts +12 -0
- package/templates/creator/app/sitemap.ts +9 -0
- package/templates/creator/app.ts +134 -0
- package/templates/creator/components/marketing.tsx +148 -0
- package/templates/creator/components/section-scroller.tsx +35 -0
- package/templates/creator/components/ui/button.tsx +56 -0
- package/templates/creator/components/ui/card.tsx +90 -0
- package/templates/creator/components.json +20 -0
- package/templates/creator/functions/subscribe.ts +82 -0
- package/templates/creator/functions/subscriberStats.ts +75 -0
- package/templates/creator/gitignore +10 -0
- package/templates/creator/lib/owner.ts +26 -0
- package/templates/creator/lib/site.config.ts +173 -0
- package/templates/creator/lib/stats.ts +30 -0
- package/templates/creator/lib/utils.ts +10 -0
- package/templates/creator/package.json +34 -0
- package/templates/creator/tsconfig.json +18 -0
- package/templates/default/app/layout.tsx +26 -27
- package/templates/default/app/page.tsx +90 -274
- package/templates/default/lib/products.ts +9 -122
- package/templates/default/lib/site.config.ts +739 -0
- package/templates/default/lib/site.ts +14 -261
- package/templates/directory/.env.example +12 -0
- package/templates/directory/AGENTS.md +61 -0
- package/templates/directory/README.md +80 -0
- package/templates/directory/app/auth-form.tsx +129 -0
- package/templates/directory/app/dashboard/dashboard-client.tsx +205 -0
- package/templates/directory/app/dashboard/page.tsx +70 -0
- package/templates/directory/app/directory-browse.tsx +328 -0
- package/templates/directory/app/error.tsx +26 -0
- package/templates/directory/app/globals.css +148 -0
- package/templates/directory/app/layout.tsx +171 -0
- package/templates/directory/app/login/page.tsx +39 -0
- package/templates/directory/app/not-found.tsx +19 -0
- package/templates/directory/app/page.tsx +50 -0
- package/templates/directory/app/robots.ts +12 -0
- package/templates/directory/app/sitemap.ts +9 -0
- package/templates/directory/app/submit/page.tsx +30 -0
- package/templates/directory/app/submit-form.tsx +151 -0
- package/templates/directory/app.ts +146 -0
- package/templates/directory/components/marketing.tsx +148 -0
- package/templates/directory/components/section-scroller.tsx +35 -0
- package/templates/directory/components/ui/button.tsx +56 -0
- package/templates/directory/components/ui/card.tsx +90 -0
- package/templates/directory/components.json +20 -0
- package/templates/directory/functions/approveSubmission.ts +45 -0
- package/templates/directory/functions/rejectSubmission.ts +20 -0
- package/templates/directory/functions/seedListings.ts +33 -0
- package/templates/directory/functions/submissionsForOwner.ts +29 -0
- package/templates/directory/functions/submitListing.ts +63 -0
- package/templates/directory/functions/upvote.ts +24 -0
- package/templates/directory/gitignore +10 -0
- package/templates/directory/lib/directory.ts +45 -0
- package/templates/directory/lib/owner.ts +26 -0
- package/templates/directory/lib/site.config.ts +130 -0
- package/templates/directory/lib/utils.ts +10 -0
- package/templates/directory/package.json +34 -0
- package/templates/directory/tsconfig.json +18 -0
- package/templates/local-service/.env.example +12 -0
- package/templates/local-service/AGENTS.md +61 -0
- package/templates/local-service/README.md +82 -0
- package/templates/local-service/app/auth-form.tsx +129 -0
- package/templates/local-service/app/booking-widget.tsx +399 -0
- package/templates/local-service/app/dashboard/dashboard-client.tsx +304 -0
- package/templates/local-service/app/dashboard/page.tsx +63 -0
- package/templates/local-service/app/error.tsx +26 -0
- package/templates/local-service/app/globals.css +148 -0
- package/templates/local-service/app/layout.tsx +151 -0
- package/templates/local-service/app/login/page.tsx +39 -0
- package/templates/local-service/app/not-found.tsx +19 -0
- package/templates/local-service/app/page.tsx +233 -0
- package/templates/local-service/app/robots.ts +12 -0
- package/templates/local-service/app/sitemap.ts +9 -0
- package/templates/local-service/app.ts +131 -0
- package/templates/local-service/components/marketing.tsx +162 -0
- package/templates/local-service/components/section-scroller.tsx +35 -0
- package/templates/local-service/components/ui/button.tsx +56 -0
- package/templates/local-service/components/ui/card.tsx +90 -0
- package/templates/local-service/components.json +20 -0
- package/templates/local-service/functions/bookingsForOwner.ts +30 -0
- package/templates/local-service/functions/cancelBooking.ts +27 -0
- package/templates/local-service/functions/confirmBooking.ts +18 -0
- package/templates/local-service/functions/createBooking.ts +98 -0
- package/templates/local-service/gitignore +10 -0
- package/templates/local-service/lib/booking.ts +24 -0
- package/templates/local-service/lib/owner.ts +26 -0
- package/templates/local-service/lib/site.config.ts +232 -0
- package/templates/local-service/lib/slots.ts +97 -0
- package/templates/local-service/lib/utils.ts +10 -0
- package/templates/local-service/package.json +34 -0
- package/templates/local-service/tsconfig.json +18 -0
- package/templates/marketplace/.env.example +9 -0
- package/templates/marketplace/AGENTS.md +61 -0
- package/templates/marketplace/README.md +78 -0
- package/templates/marketplace/app/_components/CategoryIcon.tsx +40 -0
- package/templates/marketplace/app/error.tsx +26 -0
- package/templates/marketplace/app/globals.css +64 -0
- package/templates/marketplace/app/layout.tsx +60 -0
- package/templates/marketplace/app/listing/[id]/page.tsx +163 -0
- package/templates/marketplace/app/me/page.tsx +15 -0
- package/templates/marketplace/app/not-found.tsx +20 -0
- package/templates/marketplace/app/page.tsx +159 -0
- package/templates/marketplace/app/robots.ts +12 -0
- package/templates/marketplace/app/sell/page.tsx +26 -0
- package/templates/marketplace/app/sitemap.ts +14 -0
- package/templates/marketplace/app.ts +190 -0
- package/templates/marketplace/client/AuthNav.tsx +46 -0
- package/templates/marketplace/client/LiveTicker.tsx +104 -0
- package/templates/marketplace/client/LoginCard.tsx +130 -0
- package/templates/marketplace/client/MarketProvider.tsx +148 -0
- package/templates/marketplace/client/MyMarket.tsx +180 -0
- package/templates/marketplace/client/OfferPanel.tsx +355 -0
- package/templates/marketplace/client/SeedOnEmpty.tsx +26 -0
- package/templates/marketplace/client/SellForm.tsx +160 -0
- package/templates/marketplace/client/WatchButton.tsx +88 -0
- package/templates/marketplace/client/market.ts +341 -0
- package/templates/marketplace/functions/buyNow.ts +78 -0
- package/templates/marketplace/functions/makeOffer.ts +65 -0
- package/templates/marketplace/functions/respondToOffer.ts +62 -0
- package/templates/marketplace/functions/seedMarket.ts +90 -0
- package/templates/marketplace/gitignore +10 -0
- package/templates/marketplace/package.json +35 -0
- package/templates/marketplace/tsconfig.json +14 -0
- package/templates/marketplace/ui/badge.tsx +30 -0
- package/templates/marketplace/ui/button.tsx +49 -0
- package/templates/marketplace/ui/card.tsx +48 -0
- package/templates/marketplace/ui/input.tsx +17 -0
- package/templates/marketplace/ui/label.tsx +18 -0
- package/templates/marketplace/ui/textarea.tsx +17 -0
- package/templates/marketplace/ui/tokens.css +32 -0
- package/templates/marketplace/ui/utils.ts +6 -0
- package/templates/restaurant/.env.example +12 -0
- package/templates/restaurant/AGENTS.md +61 -0
- package/templates/restaurant/README.md +77 -0
- package/templates/restaurant/app/auth-form.tsx +129 -0
- package/templates/restaurant/app/dashboard/dashboard-client.tsx +263 -0
- package/templates/restaurant/app/dashboard/page.tsx +59 -0
- package/templates/restaurant/app/error.tsx +26 -0
- package/templates/restaurant/app/globals.css +148 -0
- package/templates/restaurant/app/layout.tsx +151 -0
- package/templates/restaurant/app/login/page.tsx +39 -0
- package/templates/restaurant/app/not-found.tsx +19 -0
- package/templates/restaurant/app/page.tsx +194 -0
- package/templates/restaurant/app/reservation-widget.tsx +359 -0
- package/templates/restaurant/app/robots.ts +12 -0
- package/templates/restaurant/app/sitemap.ts +9 -0
- package/templates/restaurant/app.ts +115 -0
- package/templates/restaurant/components/marketing.tsx +162 -0
- package/templates/restaurant/components/section-scroller.tsx +35 -0
- package/templates/restaurant/components/ui/button.tsx +56 -0
- package/templates/restaurant/components/ui/card.tsx +90 -0
- package/templates/restaurant/components.json +20 -0
- package/templates/restaurant/functions/cancelReservation.ts +26 -0
- package/templates/restaurant/functions/confirmReservation.ts +17 -0
- package/templates/restaurant/functions/createReservation.ts +92 -0
- package/templates/restaurant/functions/reservationsForOwner.ts +28 -0
- package/templates/restaurant/gitignore +10 -0
- package/templates/restaurant/lib/owner.ts +26 -0
- package/templates/restaurant/lib/reservation.ts +22 -0
- package/templates/restaurant/lib/site.config.ts +218 -0
- package/templates/restaurant/lib/slots.ts +55 -0
- package/templates/restaurant/lib/utils.ts +10 -0
- package/templates/restaurant/package.json +34 -0
- package/templates/restaurant/tsconfig.json +18 -0
- package/templates/shop/.env.example +32 -0
- package/templates/shop/AGENTS.md +61 -0
- package/templates/shop/README.md +102 -0
- package/templates/shop/app/auth-form.tsx +129 -0
- package/templates/shop/app/dashboard/dashboard-client.tsx +264 -0
- package/templates/shop/app/dashboard/page.tsx +59 -0
- package/templates/shop/app/error.tsx +26 -0
- package/templates/shop/app/globals.css +148 -0
- package/templates/shop/app/layout.tsx +160 -0
- package/templates/shop/app/login/page.tsx +39 -0
- package/templates/shop/app/not-found.tsx +19 -0
- package/templates/shop/app/page.tsx +95 -0
- package/templates/shop/app/robots.ts +12 -0
- package/templates/shop/app/shop-client.tsx +436 -0
- package/templates/shop/app/sitemap.ts +9 -0
- package/templates/shop/app/success/page.tsx +33 -0
- package/templates/shop/app.ts +134 -0
- package/templates/shop/components/marketing.tsx +96 -0
- package/templates/shop/components/section-scroller.tsx +35 -0
- package/templates/shop/components/ui/button.tsx +56 -0
- package/templates/shop/components/ui/card.tsx +90 -0
- package/templates/shop/components.json +20 -0
- package/templates/shop/functions/cancelOrder.ts +33 -0
- package/templates/shop/functions/checkout.ts +130 -0
- package/templates/shop/functions/fulfillOrder.ts +17 -0
- package/templates/shop/functions/markGroupPaid.ts +26 -0
- package/templates/shop/functions/ordersForOwner.ts +28 -0
- package/templates/shop/functions/releaseGroup.ts +36 -0
- package/templates/shop/functions/reserveCart.ts +87 -0
- package/templates/shop/functions/restockProduct.ts +23 -0
- package/templates/shop/functions/seedProducts.ts +30 -0
- package/templates/shop/functions/stripeWebhook.ts +72 -0
- package/templates/shop/gitignore +10 -0
- package/templates/shop/lib/owner.ts +26 -0
- package/templates/shop/lib/shop.ts +45 -0
- package/templates/shop/lib/site.config.ts +198 -0
- package/templates/shop/lib/utils.ts +10 -0
- package/templates/shop/package.json +35 -0
- package/templates/shop/tsconfig.json +18 -0
- package/templates/waitlist/.env.example +12 -0
- package/templates/waitlist/AGENTS.md +61 -0
- package/templates/waitlist/README.md +81 -0
- package/templates/waitlist/app/auth-form.tsx +129 -0
- package/templates/waitlist/app/dashboard/dashboard-client.tsx +297 -0
- package/templates/waitlist/app/dashboard/page.tsx +70 -0
- package/templates/waitlist/app/error.tsx +26 -0
- package/templates/waitlist/app/globals.css +148 -0
- package/templates/waitlist/app/layout.tsx +158 -0
- package/templates/waitlist/app/login/page.tsx +39 -0
- package/templates/waitlist/app/not-found.tsx +19 -0
- package/templates/waitlist/app/page.tsx +119 -0
- package/templates/waitlist/app/robots.ts +12 -0
- package/templates/waitlist/app/sitemap.ts +9 -0
- package/templates/waitlist/app/waitlist-hero.tsx +219 -0
- package/templates/waitlist/app.ts +134 -0
- package/templates/waitlist/components/marketing.tsx +96 -0
- package/templates/waitlist/components/ui/button.tsx +56 -0
- package/templates/waitlist/components/ui/card.tsx +90 -0
- package/templates/waitlist/components.json +20 -0
- package/templates/waitlist/functions/joinWaitlist.ts +82 -0
- package/templates/waitlist/functions/waitlistStats.ts +75 -0
- package/templates/waitlist/gitignore +10 -0
- package/templates/waitlist/lib/owner.ts +26 -0
- package/templates/waitlist/lib/site.config.ts +178 -0
- package/templates/waitlist/lib/stats.ts +30 -0
- package/templates/waitlist/lib/utils.ts +10 -0
- package/templates/waitlist/package.json +34 -0
- package/templates/waitlist/tsconfig.json +18 -0
|
@@ -0,0 +1,1440 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { db, callFn } from "@pylonsync/react";
|
|
5
|
+
import { useAuth } from "@pylonsync/client";
|
|
6
|
+
import {
|
|
7
|
+
money,
|
|
8
|
+
parseLineItems,
|
|
9
|
+
lineItemsTotal,
|
|
10
|
+
type InquiryRow,
|
|
11
|
+
type OwnerInquiriesResult,
|
|
12
|
+
type ProjectRow,
|
|
13
|
+
type ClientRow,
|
|
14
|
+
type InvoiceRow,
|
|
15
|
+
type InvoiceLineItem,
|
|
16
|
+
type OwnerClientsResult,
|
|
17
|
+
type OwnerInvoicesResult,
|
|
18
|
+
} from "@/lib/agency";
|
|
19
|
+
import { siteConfig } from "@/lib/site.config";
|
|
20
|
+
|
|
21
|
+
// The studio back-office. One owner-gated dashboard with four tabs:
|
|
22
|
+
// • Pipeline — live inquiries + the public "slots open" capacity counter.
|
|
23
|
+
// • Work — the portfolio. Projects are public-read, so this reads them
|
|
24
|
+
// with a LIVE `db.useQuery("Project")` (drafts included) and
|
|
25
|
+
// toggling `selected` re-curates the homepage in real time.
|
|
26
|
+
// • Clients — the CRM. PII, so it loads through the owner-gated
|
|
27
|
+
// `clientsForOwner` and never travels over entity sync.
|
|
28
|
+
// • Invoices — billing. Money + client data, owner-gated like Clients.
|
|
29
|
+
//
|
|
30
|
+
// The whole dashboard is gated to PYLON_OWNER_EMAIL: one owner probe up front
|
|
31
|
+
// (inquiriesForOwner) decides access; a non-owner sees the owner-only card.
|
|
32
|
+
type Tab = "pipeline" | "work" | "clients" | "invoices";
|
|
33
|
+
|
|
34
|
+
export function AgencyDashboard({ userEmail }: { userEmail: string }) {
|
|
35
|
+
const [tab, setTab] = useState<Tab>("pipeline");
|
|
36
|
+
const [authorized, setAuthorized] = useState<boolean | null>(null);
|
|
37
|
+
const [error, setError] = useState<string | null>(null);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
let cancelled = false;
|
|
41
|
+
(async () => {
|
|
42
|
+
try {
|
|
43
|
+
const r = await callFn<OwnerInquiriesResult>("inquiriesForOwner", {});
|
|
44
|
+
if (cancelled) return;
|
|
45
|
+
if (!r.authorized) {
|
|
46
|
+
setAuthorized(false);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
setAuthorized(true);
|
|
50
|
+
// Seed demo clients + invoices once, for the owner only (idempotent).
|
|
51
|
+
void callFn("seedStudioBackoffice", {}).catch(() => {});
|
|
52
|
+
} catch (e) {
|
|
53
|
+
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
|
54
|
+
}
|
|
55
|
+
})();
|
|
56
|
+
return () => {
|
|
57
|
+
cancelled = true;
|
|
58
|
+
};
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
if (error) {
|
|
62
|
+
return <div className="rounded-xl border border-red-200 bg-red-50 px-5 py-4 text-sm text-red-700">{error}</div>;
|
|
63
|
+
}
|
|
64
|
+
if (authorized === null) return <Skeleton />;
|
|
65
|
+
if (!authorized) return <OwnerOnly email={userEmail} />;
|
|
66
|
+
|
|
67
|
+
const tabs: { id: Tab; label: string }[] = [
|
|
68
|
+
{ id: "pipeline", label: "Pipeline" },
|
|
69
|
+
{ id: "work", label: "Work" },
|
|
70
|
+
{ id: "clients", label: "Clients" },
|
|
71
|
+
{ id: "invoices", label: "Invoices" },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className="space-y-6">
|
|
76
|
+
<nav className="flex gap-1 border-b border-zinc-200">
|
|
77
|
+
{tabs.map((t) => (
|
|
78
|
+
<button
|
|
79
|
+
key={t.id}
|
|
80
|
+
type="button"
|
|
81
|
+
onClick={() => setTab(t.id)}
|
|
82
|
+
className={
|
|
83
|
+
"-mb-px border-b-2 px-3.5 py-2.5 text-[13.5px] font-medium transition-colors " +
|
|
84
|
+
(tab === t.id
|
|
85
|
+
? "border-brand text-zinc-900"
|
|
86
|
+
: "border-transparent text-zinc-500 hover:text-zinc-800")
|
|
87
|
+
}
|
|
88
|
+
>
|
|
89
|
+
{t.label}
|
|
90
|
+
</button>
|
|
91
|
+
))}
|
|
92
|
+
</nav>
|
|
93
|
+
|
|
94
|
+
{tab === "pipeline" ? <PipelinePanel /> : null}
|
|
95
|
+
{tab === "work" ? <WorkPanel /> : null}
|
|
96
|
+
{tab === "clients" ? <ClientsPanel /> : null}
|
|
97
|
+
{tab === "invoices" ? <InvoicesPanel /> : null}
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* =============================== PIPELINE =============================== */
|
|
103
|
+
|
|
104
|
+
interface CapacityRow {
|
|
105
|
+
id: string;
|
|
106
|
+
label: string;
|
|
107
|
+
openSlots: number;
|
|
108
|
+
updatedAt: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Liveness rides the public Capacity row: `db.useQuery("Capacity")` re-renders
|
|
112
|
+
// the instant slots change (cross-tab). Leads come from the owner-gated
|
|
113
|
+
// inquiriesForOwner, refetched whenever capacity changes (which is exactly when
|
|
114
|
+
// a lead is booked/released), so the pipeline stays live without PII syncing.
|
|
115
|
+
function PipelinePanel() {
|
|
116
|
+
const { data: caps } = db.useQuery<CapacityRow>("Capacity");
|
|
117
|
+
const cap = caps[0];
|
|
118
|
+
const liveKey = `${cap?.openSlots ?? "?"}:${cap?.label ?? ""}`;
|
|
119
|
+
|
|
120
|
+
const [inquiries, setInquiries] = useState<InquiryRow[] | null>(null);
|
|
121
|
+
const [busyId, setBusyId] = useState<string | null>(null);
|
|
122
|
+
|
|
123
|
+
async function load() {
|
|
124
|
+
const r = await callFn<OwnerInquiriesResult>("inquiriesForOwner", {});
|
|
125
|
+
if (r.authorized) setInquiries(r.inquiries);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
void load();
|
|
130
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
131
|
+
}, [liveKey]);
|
|
132
|
+
|
|
133
|
+
async function act(id: string, fn: "bookInquiry" | "declineInquiry") {
|
|
134
|
+
setBusyId(id);
|
|
135
|
+
try {
|
|
136
|
+
await callFn(fn, { inquiryId: id });
|
|
137
|
+
await load();
|
|
138
|
+
} finally {
|
|
139
|
+
setBusyId(null);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!inquiries) return <Skeleton compact />;
|
|
144
|
+
|
|
145
|
+
const newCount = inquiries.filter((i) => i.status === "new").length;
|
|
146
|
+
const bookedCount = inquiries.filter((i) => i.status === "booked").length;
|
|
147
|
+
const active = inquiries.filter((i) => i.status !== "declined");
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div className="space-y-6">
|
|
151
|
+
<PanelHead title="Pipeline" sub="Live — leads land here the moment they're sent. Booking one drops the open-slot count on your site instantly." />
|
|
152
|
+
|
|
153
|
+
<div className="grid gap-4 sm:grid-cols-3">
|
|
154
|
+
<Stat label="Open slots" value={String(cap?.openSlots ?? 0)} hint={cap?.label} />
|
|
155
|
+
<Stat label="New leads" value={String(newCount)} />
|
|
156
|
+
<Stat label="Booked" value={String(bookedCount)} />
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<CapacityCard cap={cap} />
|
|
160
|
+
|
|
161
|
+
<Card title="Inquiries" count={active.length}>
|
|
162
|
+
{inquiries.length === 0 ? (
|
|
163
|
+
<Empty>No inquiries yet — share your site.</Empty>
|
|
164
|
+
) : (
|
|
165
|
+
<ul className="divide-y divide-zinc-100">
|
|
166
|
+
{inquiries.map((i) => (
|
|
167
|
+
<li key={i.id} className="px-4 py-3.5">
|
|
168
|
+
<div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2">
|
|
169
|
+
<div className="min-w-0 flex-1">
|
|
170
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
171
|
+
<span className="text-[14px] font-medium text-zinc-900">{i.name}</span>
|
|
172
|
+
<Pill kind="inquiry" status={i.status} />
|
|
173
|
+
</div>
|
|
174
|
+
<div className="mt-0.5 truncate text-[12.5px] text-zinc-500">
|
|
175
|
+
{i.email}
|
|
176
|
+
{i.company ? ` · ${i.company}` : ""}
|
|
177
|
+
{i.projectType ? ` · ${i.projectType}` : ""}
|
|
178
|
+
{i.budget ? ` · ${i.budget}` : ""}
|
|
179
|
+
</div>
|
|
180
|
+
{i.message ? (
|
|
181
|
+
<p className="mt-1.5 line-clamp-2 text-[13px] leading-relaxed text-zinc-600">{i.message}</p>
|
|
182
|
+
) : null}
|
|
183
|
+
</div>
|
|
184
|
+
<div className="flex items-center gap-2">
|
|
185
|
+
{i.status !== "booked" ? (
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
disabled={busyId === i.id}
|
|
189
|
+
onClick={() => act(i.id, "bookInquiry")}
|
|
190
|
+
className="rounded-md bg-brand px-3 py-1.5 text-[12.5px] font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50"
|
|
191
|
+
>
|
|
192
|
+
{busyId === i.id ? "…" : "Book"}
|
|
193
|
+
</button>
|
|
194
|
+
) : null}
|
|
195
|
+
{i.status !== "declined" ? (
|
|
196
|
+
<button
|
|
197
|
+
type="button"
|
|
198
|
+
disabled={busyId === i.id}
|
|
199
|
+
onClick={() => act(i.id, "declineInquiry")}
|
|
200
|
+
className="rounded-md border border-zinc-300 px-3 py-1.5 text-[12.5px] font-medium text-zinc-600 transition-colors hover:border-red-300 hover:text-red-600 disabled:opacity-50"
|
|
201
|
+
>
|
|
202
|
+
{i.status === "booked" ? "Release" : "Decline"}
|
|
203
|
+
</button>
|
|
204
|
+
) : null}
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
</li>
|
|
208
|
+
))}
|
|
209
|
+
</ul>
|
|
210
|
+
)}
|
|
211
|
+
</Card>
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function CapacityCard({ cap }: { cap?: CapacityRow }) {
|
|
217
|
+
const [label, setLabel] = useState(cap?.label ?? "");
|
|
218
|
+
const [slots, setSlots] = useState(String(cap?.openSlots ?? 0));
|
|
219
|
+
const [saving, setSaving] = useState(false);
|
|
220
|
+
const [saved, setSaved] = useState(false);
|
|
221
|
+
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
setLabel(cap?.label ?? "");
|
|
224
|
+
setSlots(String(cap?.openSlots ?? 0));
|
|
225
|
+
}, [cap?.label, cap?.openSlots]);
|
|
226
|
+
|
|
227
|
+
async function save(e: React.FormEvent) {
|
|
228
|
+
e.preventDefault();
|
|
229
|
+
setSaving(true);
|
|
230
|
+
setSaved(false);
|
|
231
|
+
try {
|
|
232
|
+
await callFn("setCapacity", { label: label.trim(), openSlots: Math.max(0, parseInt(slots, 10) || 0) });
|
|
233
|
+
setSaved(true);
|
|
234
|
+
} finally {
|
|
235
|
+
setSaving(false);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<form onSubmit={save} className="rounded-xl border border-zinc-200 bg-white p-4">
|
|
241
|
+
<div className="text-sm font-semibold text-zinc-900">Availability</div>
|
|
242
|
+
<p className="mt-1 text-[13px] text-zinc-500">Shown live on your site as “N project slots open”.</p>
|
|
243
|
+
<div className="mt-4 flex flex-wrap items-end gap-3">
|
|
244
|
+
<label className="block">
|
|
245
|
+
<span className="mb-1 block text-[12px] font-medium text-zinc-600">Booking window</span>
|
|
246
|
+
<input
|
|
247
|
+
value={label}
|
|
248
|
+
onChange={(e) => { setLabel(e.target.value); setSaved(false); }}
|
|
249
|
+
placeholder="Q3 2026"
|
|
250
|
+
className="h-9 w-40 rounded-lg border border-zinc-300 px-3 text-[14px] outline-none focus:border-brand focus:ring-2 focus:ring-brand/20"
|
|
251
|
+
/>
|
|
252
|
+
</label>
|
|
253
|
+
<label className="block">
|
|
254
|
+
<span className="mb-1 block text-[12px] font-medium text-zinc-600">Open slots</span>
|
|
255
|
+
<input
|
|
256
|
+
type="number"
|
|
257
|
+
min={0}
|
|
258
|
+
value={slots}
|
|
259
|
+
onChange={(e) => { setSlots(e.target.value); setSaved(false); }}
|
|
260
|
+
className="h-9 w-24 rounded-lg border border-zinc-300 px-3 text-[14px] tabular-nums outline-none focus:border-brand focus:ring-2 focus:ring-brand/20"
|
|
261
|
+
/>
|
|
262
|
+
</label>
|
|
263
|
+
<button
|
|
264
|
+
type="submit"
|
|
265
|
+
disabled={saving}
|
|
266
|
+
className="h-9 rounded-lg bg-zinc-900 px-4 text-[13px] font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-50"
|
|
267
|
+
>
|
|
268
|
+
{saving ? "Saving…" : saved ? "Saved ✓" : "Save"}
|
|
269
|
+
</button>
|
|
270
|
+
</div>
|
|
271
|
+
</form>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/* ================================= WORK ================================ */
|
|
276
|
+
|
|
277
|
+
// Projects are public-read, so this is a LIVE query — toggling `selected` here
|
|
278
|
+
// updates the homepage's "Selected work" on its next render, and any other open
|
|
279
|
+
// dashboard tab instantly. Writes go through the owner-gated functions.
|
|
280
|
+
function WorkPanel() {
|
|
281
|
+
const { data: projects } = db.useQuery<ProjectRow>("Project");
|
|
282
|
+
const [editing, setEditing] = useState<ProjectRow | "new" | null>(null);
|
|
283
|
+
const [busyId, setBusyId] = useState<string | null>(null);
|
|
284
|
+
|
|
285
|
+
const ordered = useMemo(
|
|
286
|
+
() => [...projects].sort((a, b) => a.order - b.order || (a.createdAt < b.createdAt ? -1 : 1)),
|
|
287
|
+
[projects],
|
|
288
|
+
);
|
|
289
|
+
const selectedCount = projects.filter((p) => p.selected && p.published).length;
|
|
290
|
+
|
|
291
|
+
async function flag(id: string, patch: { selected?: boolean; published?: boolean }) {
|
|
292
|
+
setBusyId(id);
|
|
293
|
+
try {
|
|
294
|
+
await callFn("setProjectFlags", { id, ...patch });
|
|
295
|
+
} finally {
|
|
296
|
+
setBusyId(null);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
async function remove(p: ProjectRow) {
|
|
300
|
+
if (!confirm(`Delete “${p.title}”? This can't be undone.`)) return;
|
|
301
|
+
setBusyId(p.id);
|
|
302
|
+
try {
|
|
303
|
+
await callFn("deleteProject", { id: p.id });
|
|
304
|
+
} finally {
|
|
305
|
+
setBusyId(null);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return (
|
|
310
|
+
<div className="space-y-6">
|
|
311
|
+
<PanelHead
|
|
312
|
+
title="Work"
|
|
313
|
+
sub={`Your portfolio. ${selectedCount} featured on the homepage. Toggle the star to feature, the eye to publish.`}
|
|
314
|
+
action={<NewButton onClick={() => setEditing("new")}>New project</NewButton>}
|
|
315
|
+
/>
|
|
316
|
+
|
|
317
|
+
<Card title="Projects" count={projects.length}>
|
|
318
|
+
{ordered.length === 0 ? (
|
|
319
|
+
<Empty>No projects yet — add your first case study.</Empty>
|
|
320
|
+
) : (
|
|
321
|
+
<ul className="divide-y divide-zinc-100">
|
|
322
|
+
{ordered.map((p) => (
|
|
323
|
+
<li key={p.id} className="flex items-center gap-3 px-4 py-3">
|
|
324
|
+
<div className="min-w-0 flex-1">
|
|
325
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
326
|
+
<span className="truncate text-[14px] font-medium text-zinc-900">{p.title}</span>
|
|
327
|
+
{!p.published ? (
|
|
328
|
+
<span className="rounded bg-zinc-100 px-1.5 py-0.5 text-[10px] font-medium text-zinc-500">draft</span>
|
|
329
|
+
) : null}
|
|
330
|
+
{p.selected && p.published ? (
|
|
331
|
+
<span className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">featured</span>
|
|
332
|
+
) : null}
|
|
333
|
+
</div>
|
|
334
|
+
<div className="mt-0.5 truncate text-[12.5px] text-zinc-500">
|
|
335
|
+
{p.client}
|
|
336
|
+
{p.summary ? ` · ${p.summary}` : ""}
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
340
|
+
<IconToggle
|
|
341
|
+
on={p.selected}
|
|
342
|
+
disabled={busyId === p.id}
|
|
343
|
+
title={p.selected ? "Featured on homepage" : "Feature on homepage"}
|
|
344
|
+
onClick={() => flag(p.id, { selected: !p.selected })}
|
|
345
|
+
>
|
|
346
|
+
{p.selected ? "★" : "☆"}
|
|
347
|
+
</IconToggle>
|
|
348
|
+
<IconToggle
|
|
349
|
+
on={p.published}
|
|
350
|
+
disabled={busyId === p.id}
|
|
351
|
+
title={p.published ? "Published" : "Draft (hidden)"}
|
|
352
|
+
onClick={() => flag(p.id, { published: !p.published })}
|
|
353
|
+
>
|
|
354
|
+
{p.published ? "👁" : "🚫"}
|
|
355
|
+
</IconToggle>
|
|
356
|
+
<a
|
|
357
|
+
href={`/work/${p.slug}`}
|
|
358
|
+
target="_blank"
|
|
359
|
+
rel="noreferrer"
|
|
360
|
+
className="rounded-md px-2 py-1 text-[12px] text-zinc-500 hover:bg-zinc-100 hover:text-zinc-800"
|
|
361
|
+
title="View case study"
|
|
362
|
+
>
|
|
363
|
+
↗
|
|
364
|
+
</a>
|
|
365
|
+
<button
|
|
366
|
+
type="button"
|
|
367
|
+
onClick={() => setEditing(p)}
|
|
368
|
+
className="rounded-md px-2.5 py-1 text-[12.5px] font-medium text-zinc-600 hover:bg-zinc-100"
|
|
369
|
+
>
|
|
370
|
+
Edit
|
|
371
|
+
</button>
|
|
372
|
+
<button
|
|
373
|
+
type="button"
|
|
374
|
+
disabled={busyId === p.id}
|
|
375
|
+
onClick={() => remove(p)}
|
|
376
|
+
className="rounded-md px-2 py-1 text-[12.5px] text-zinc-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
|
|
377
|
+
title="Delete"
|
|
378
|
+
>
|
|
379
|
+
✕
|
|
380
|
+
</button>
|
|
381
|
+
</div>
|
|
382
|
+
</li>
|
|
383
|
+
))}
|
|
384
|
+
</ul>
|
|
385
|
+
)}
|
|
386
|
+
</Card>
|
|
387
|
+
|
|
388
|
+
{editing ? (
|
|
389
|
+
<ProjectForm
|
|
390
|
+
initial={editing === "new" ? null : editing}
|
|
391
|
+
nextOrder={projects.length}
|
|
392
|
+
onClose={() => setEditing(null)}
|
|
393
|
+
/>
|
|
394
|
+
) : null}
|
|
395
|
+
</div>
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function ProjectForm({
|
|
400
|
+
initial,
|
|
401
|
+
nextOrder,
|
|
402
|
+
onClose,
|
|
403
|
+
}: {
|
|
404
|
+
initial: ProjectRow | null;
|
|
405
|
+
nextOrder: number;
|
|
406
|
+
onClose: () => void;
|
|
407
|
+
}) {
|
|
408
|
+
const [f, setF] = useState({
|
|
409
|
+
title: initial?.title ?? "",
|
|
410
|
+
client: initial?.client ?? "",
|
|
411
|
+
summary: initial?.summary ?? "",
|
|
412
|
+
year: initial?.year ?? "",
|
|
413
|
+
tags: initial?.tags ?? "",
|
|
414
|
+
selected: initial?.selected ?? false,
|
|
415
|
+
published: initial?.published ?? true,
|
|
416
|
+
order: initial?.order ?? nextOrder,
|
|
417
|
+
challenge: initial?.challenge ?? "",
|
|
418
|
+
approach: initial?.approach ?? "",
|
|
419
|
+
outcome: initial?.outcome ?? "",
|
|
420
|
+
liveUrl: initial?.liveUrl ?? "",
|
|
421
|
+
});
|
|
422
|
+
const [saving, setSaving] = useState(false);
|
|
423
|
+
const [err, setErr] = useState<string | null>(null);
|
|
424
|
+
const set = <K extends keyof typeof f>(k: K, v: (typeof f)[K]) => setF((s) => ({ ...s, [k]: v }) as typeof f);
|
|
425
|
+
|
|
426
|
+
async function save() {
|
|
427
|
+
if (!f.title.trim()) { setErr("A title is required."); return; }
|
|
428
|
+
setSaving(true);
|
|
429
|
+
setErr(null);
|
|
430
|
+
try {
|
|
431
|
+
await callFn("upsertProject", {
|
|
432
|
+
id: initial?.id,
|
|
433
|
+
title: f.title.trim(),
|
|
434
|
+
client: f.client.trim() || undefined,
|
|
435
|
+
summary: f.summary.trim() || undefined,
|
|
436
|
+
year: f.year.trim() || undefined,
|
|
437
|
+
tags: f.tags.trim() || undefined,
|
|
438
|
+
selected: f.selected,
|
|
439
|
+
published: f.published,
|
|
440
|
+
order: Number(f.order) || 0,
|
|
441
|
+
challenge: f.challenge.trim() || undefined,
|
|
442
|
+
approach: f.approach.trim() || undefined,
|
|
443
|
+
outcome: f.outcome.trim() || undefined,
|
|
444
|
+
liveUrl: f.liveUrl.trim() || undefined,
|
|
445
|
+
});
|
|
446
|
+
onClose();
|
|
447
|
+
} catch (e) {
|
|
448
|
+
setErr(e instanceof Error ? e.message : "Couldn't save — try again.");
|
|
449
|
+
setSaving(false);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return (
|
|
454
|
+
<Modal title={initial ? "Edit project" : "New project"} onClose={onClose} onSubmit={save} saving={saving} error={err}>
|
|
455
|
+
<Field label="Title" required>
|
|
456
|
+
<input value={f.title} onChange={(e) => set("title", e.target.value)} className={inputCls} autoFocus />
|
|
457
|
+
</Field>
|
|
458
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
459
|
+
<Field label="Client label" hint="e.g. Fintech · 0→1">
|
|
460
|
+
<input value={f.client} onChange={(e) => set("client", e.target.value)} className={inputCls} />
|
|
461
|
+
</Field>
|
|
462
|
+
<Field label="Year">
|
|
463
|
+
<input value={f.year} onChange={(e) => set("year", e.target.value)} placeholder="2026" className={inputCls} />
|
|
464
|
+
</Field>
|
|
465
|
+
</div>
|
|
466
|
+
<Field label="Summary" hint="One line, shown on the card">
|
|
467
|
+
<input value={f.summary} onChange={(e) => set("summary", e.target.value)} className={inputCls} />
|
|
468
|
+
</Field>
|
|
469
|
+
<Field label="Tags" hint="Comma-separated">
|
|
470
|
+
<input value={f.tags} onChange={(e) => set("tags", e.target.value)} placeholder="Product design, iOS" className={inputCls} />
|
|
471
|
+
</Field>
|
|
472
|
+
<Field label="The challenge">
|
|
473
|
+
<textarea value={f.challenge} onChange={(e) => set("challenge", e.target.value)} rows={2} className={inputCls + " resize-none py-2"} />
|
|
474
|
+
</Field>
|
|
475
|
+
<Field label="Our approach">
|
|
476
|
+
<textarea value={f.approach} onChange={(e) => set("approach", e.target.value)} rows={2} className={inputCls + " resize-none py-2"} />
|
|
477
|
+
</Field>
|
|
478
|
+
<Field label="The outcome">
|
|
479
|
+
<textarea value={f.outcome} onChange={(e) => set("outcome", e.target.value)} rows={2} className={inputCls + " resize-none py-2"} />
|
|
480
|
+
</Field>
|
|
481
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
482
|
+
<Field label="Live URL">
|
|
483
|
+
<input value={f.liveUrl} onChange={(e) => set("liveUrl", e.target.value)} placeholder="https://…" className={inputCls} />
|
|
484
|
+
</Field>
|
|
485
|
+
<Field label="Order" hint="Lower shows first">
|
|
486
|
+
<input type="number" value={f.order} onChange={(e) => set("order", Number(e.target.value))} className={inputCls + " tabular-nums"} />
|
|
487
|
+
</Field>
|
|
488
|
+
</div>
|
|
489
|
+
<div className="flex flex-wrap gap-5 pt-1">
|
|
490
|
+
<Switch label="Feature on homepage" checked={f.selected} onChange={(v) => set("selected", v)} />
|
|
491
|
+
<Switch label="Published" checked={f.published} onChange={(v) => set("published", v)} />
|
|
492
|
+
</div>
|
|
493
|
+
</Modal>
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/* =============================== CLIENTS =============================== */
|
|
498
|
+
|
|
499
|
+
function ClientsPanel() {
|
|
500
|
+
const [clients, setClients] = useState<ClientRow[] | null>(null);
|
|
501
|
+
const [editing, setEditing] = useState<ClientRow | "new" | null>(null);
|
|
502
|
+
const [note, setNote] = useState<string | null>(null);
|
|
503
|
+
const [busyId, setBusyId] = useState<string | null>(null);
|
|
504
|
+
|
|
505
|
+
async function load() {
|
|
506
|
+
const r = await callFn<OwnerClientsResult>("clientsForOwner", {});
|
|
507
|
+
if (r.authorized) setClients(r.clients);
|
|
508
|
+
}
|
|
509
|
+
useEffect(() => { void load(); }, []);
|
|
510
|
+
|
|
511
|
+
async function remove(c: ClientRow) {
|
|
512
|
+
if (!confirm(`Delete ${c.name}?`)) return;
|
|
513
|
+
setBusyId(c.id);
|
|
514
|
+
setNote(null);
|
|
515
|
+
try {
|
|
516
|
+
const r = await callFn<{ ok: boolean; invoices: number }>("deleteClient", { id: c.id });
|
|
517
|
+
if (!r.ok) setNote(`${c.name} has ${r.invoices} invoice${r.invoices === 1 ? "" : "s"} — delete those first.`);
|
|
518
|
+
else await load();
|
|
519
|
+
} finally {
|
|
520
|
+
setBusyId(null);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (!clients) return <Skeleton compact />;
|
|
525
|
+
|
|
526
|
+
return (
|
|
527
|
+
<div className="space-y-6">
|
|
528
|
+
<PanelHead
|
|
529
|
+
title="Clients"
|
|
530
|
+
sub="Your CRM — private contact details, never exposed to the public site."
|
|
531
|
+
action={<NewButton onClick={() => setEditing("new")}>New client</NewButton>}
|
|
532
|
+
/>
|
|
533
|
+
{note ? (
|
|
534
|
+
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-2.5 text-[13px] text-amber-800">{note}</div>
|
|
535
|
+
) : null}
|
|
536
|
+
|
|
537
|
+
<Card title="Contacts" count={clients.length}>
|
|
538
|
+
{clients.length === 0 ? (
|
|
539
|
+
<Empty>No clients yet — add your first contact.</Empty>
|
|
540
|
+
) : (
|
|
541
|
+
<ul className="divide-y divide-zinc-100">
|
|
542
|
+
{clients.map((c) => (
|
|
543
|
+
<li key={c.id} className="flex items-start gap-3 px-4 py-3.5">
|
|
544
|
+
<div className="min-w-0 flex-1">
|
|
545
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
546
|
+
<span className="text-[14px] font-medium text-zinc-900">{c.name}</span>
|
|
547
|
+
{c.company ? <span className="text-[13px] text-zinc-500">{c.company}</span> : null}
|
|
548
|
+
<Pill kind="client" status={c.status} />
|
|
549
|
+
</div>
|
|
550
|
+
<div className="mt-0.5 truncate text-[12.5px] text-zinc-500">
|
|
551
|
+
{[c.email, c.phone].filter(Boolean).join(" · ") || "No contact details"}
|
|
552
|
+
</div>
|
|
553
|
+
{c.notes ? <p className="mt-1 line-clamp-2 text-[13px] leading-relaxed text-zinc-600">{c.notes}</p> : null}
|
|
554
|
+
</div>
|
|
555
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
556
|
+
<button type="button" onClick={() => setEditing(c)} className="rounded-md px-2.5 py-1 text-[12.5px] font-medium text-zinc-600 hover:bg-zinc-100">
|
|
557
|
+
Edit
|
|
558
|
+
</button>
|
|
559
|
+
<button
|
|
560
|
+
type="button"
|
|
561
|
+
disabled={busyId === c.id}
|
|
562
|
+
onClick={() => remove(c)}
|
|
563
|
+
className="rounded-md px-2 py-1 text-[12.5px] text-zinc-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
|
|
564
|
+
title="Delete"
|
|
565
|
+
>
|
|
566
|
+
✕
|
|
567
|
+
</button>
|
|
568
|
+
</div>
|
|
569
|
+
</li>
|
|
570
|
+
))}
|
|
571
|
+
</ul>
|
|
572
|
+
)}
|
|
573
|
+
</Card>
|
|
574
|
+
|
|
575
|
+
{editing ? (
|
|
576
|
+
<ClientForm initial={editing === "new" ? null : editing} onClose={() => setEditing(null)} onSaved={load} />
|
|
577
|
+
) : null}
|
|
578
|
+
</div>
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function ClientForm({
|
|
583
|
+
initial,
|
|
584
|
+
onClose,
|
|
585
|
+
onSaved,
|
|
586
|
+
}: {
|
|
587
|
+
initial: ClientRow | null;
|
|
588
|
+
onClose: () => void;
|
|
589
|
+
onSaved: () => Promise<void>;
|
|
590
|
+
}) {
|
|
591
|
+
const [f, setF] = useState({
|
|
592
|
+
name: initial?.name ?? "",
|
|
593
|
+
company: initial?.company ?? "",
|
|
594
|
+
email: initial?.email ?? "",
|
|
595
|
+
phone: initial?.phone ?? "",
|
|
596
|
+
status: initial?.status ?? "prospect",
|
|
597
|
+
notes: initial?.notes ?? "",
|
|
598
|
+
});
|
|
599
|
+
const [saving, setSaving] = useState(false);
|
|
600
|
+
const [err, setErr] = useState<string | null>(null);
|
|
601
|
+
const set = <K extends keyof typeof f>(k: K, v: (typeof f)[K]) => setF((s) => ({ ...s, [k]: v }) as typeof f);
|
|
602
|
+
|
|
603
|
+
async function save() {
|
|
604
|
+
if (!f.name.trim()) { setErr("A name is required."); return; }
|
|
605
|
+
setSaving(true);
|
|
606
|
+
setErr(null);
|
|
607
|
+
try {
|
|
608
|
+
await callFn("upsertClient", {
|
|
609
|
+
id: initial?.id,
|
|
610
|
+
name: f.name.trim(),
|
|
611
|
+
company: f.company.trim() || undefined,
|
|
612
|
+
email: f.email.trim() || undefined,
|
|
613
|
+
phone: f.phone.trim() || undefined,
|
|
614
|
+
status: f.status,
|
|
615
|
+
notes: f.notes.trim() || undefined,
|
|
616
|
+
});
|
|
617
|
+
await onSaved();
|
|
618
|
+
onClose();
|
|
619
|
+
} catch (e) {
|
|
620
|
+
setErr(e instanceof Error ? e.message : "Couldn't save — try again.");
|
|
621
|
+
setSaving(false);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return (
|
|
626
|
+
<Modal title={initial ? "Edit client" : "New client"} onClose={onClose} onSubmit={save} saving={saving} error={err}>
|
|
627
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
628
|
+
<Field label="Name" required>
|
|
629
|
+
<input value={f.name} onChange={(e) => set("name", e.target.value)} className={inputCls} autoFocus />
|
|
630
|
+
</Field>
|
|
631
|
+
<Field label="Company">
|
|
632
|
+
<input value={f.company} onChange={(e) => set("company", e.target.value)} className={inputCls} />
|
|
633
|
+
</Field>
|
|
634
|
+
<Field label="Email">
|
|
635
|
+
<input type="email" value={f.email} onChange={(e) => set("email", e.target.value)} className={inputCls} />
|
|
636
|
+
</Field>
|
|
637
|
+
<Field label="Phone">
|
|
638
|
+
<input value={f.phone} onChange={(e) => set("phone", e.target.value)} className={inputCls} />
|
|
639
|
+
</Field>
|
|
640
|
+
</div>
|
|
641
|
+
<Field label="Status">
|
|
642
|
+
<select value={f.status} onChange={(e) => set("status", e.target.value)} className={inputCls}>
|
|
643
|
+
<option value="prospect">Prospect</option>
|
|
644
|
+
<option value="active">Active</option>
|
|
645
|
+
<option value="past">Past</option>
|
|
646
|
+
</select>
|
|
647
|
+
</Field>
|
|
648
|
+
<Field label="Notes">
|
|
649
|
+
<textarea value={f.notes} onChange={(e) => set("notes", e.target.value)} rows={3} className={inputCls + " resize-none py-2"} />
|
|
650
|
+
</Field>
|
|
651
|
+
</Modal>
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/* =============================== INVOICES ============================== */
|
|
656
|
+
|
|
657
|
+
function InvoicesPanel() {
|
|
658
|
+
const [invoices, setInvoices] = useState<InvoiceRow[] | null>(null);
|
|
659
|
+
const [clients, setClients] = useState<ClientRow[]>([]);
|
|
660
|
+
const { data: projects } = db.useQuery<ProjectRow>("Project");
|
|
661
|
+
const [editing, setEditing] = useState<InvoiceRow | "new" | null>(null);
|
|
662
|
+
const [viewing, setViewing] = useState<InvoiceRow | null>(null);
|
|
663
|
+
const [busyId, setBusyId] = useState<string | null>(null);
|
|
664
|
+
|
|
665
|
+
async function load() {
|
|
666
|
+
const [inv, cl] = await Promise.all([
|
|
667
|
+
callFn<OwnerInvoicesResult>("invoicesForOwner", {}),
|
|
668
|
+
callFn<OwnerClientsResult>("clientsForOwner", {}),
|
|
669
|
+
]);
|
|
670
|
+
if (inv.authorized) setInvoices(inv.invoices);
|
|
671
|
+
if (cl.authorized) setClients(cl.clients);
|
|
672
|
+
// Keep an open view in sync after an edit.
|
|
673
|
+
if (inv.authorized) {
|
|
674
|
+
setViewing((cur) => (cur ? inv.invoices.find((i) => i.id === cur.id) ?? null : null));
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
useEffect(() => { void load(); }, []);
|
|
678
|
+
|
|
679
|
+
const clientFor = (inv: InvoiceRow) => clients.find((c) => c.id === inv.clientId);
|
|
680
|
+
|
|
681
|
+
async function setStatus(id: string, status: string) {
|
|
682
|
+
setBusyId(id);
|
|
683
|
+
try {
|
|
684
|
+
await callFn("setInvoiceStatus", { id, status });
|
|
685
|
+
await load();
|
|
686
|
+
} finally {
|
|
687
|
+
setBusyId(null);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
async function remove(inv: InvoiceRow) {
|
|
691
|
+
if (!confirm(`Delete ${inv.number}?`)) return;
|
|
692
|
+
setBusyId(inv.id);
|
|
693
|
+
try {
|
|
694
|
+
await callFn("deleteInvoice", { id: inv.id });
|
|
695
|
+
await load();
|
|
696
|
+
} finally {
|
|
697
|
+
setBusyId(null);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const totals = useMemo(() => {
|
|
702
|
+
const list = invoices ?? [];
|
|
703
|
+
const paid = list.filter((i) => i.status === "paid").reduce((s, i) => s + i.amountCents, 0);
|
|
704
|
+
const outstanding = list
|
|
705
|
+
.filter((i) => i.status === "sent" || i.status === "overdue")
|
|
706
|
+
.reduce((s, i) => s + i.amountCents, 0);
|
|
707
|
+
return { paid, outstanding };
|
|
708
|
+
}, [invoices]);
|
|
709
|
+
|
|
710
|
+
if (!invoices) return <Skeleton compact />;
|
|
711
|
+
|
|
712
|
+
return (
|
|
713
|
+
<div className="space-y-6">
|
|
714
|
+
<PanelHead
|
|
715
|
+
title="Invoices"
|
|
716
|
+
sub="Billing — private. View any invoice, export it to PDF, and track what's paid vs outstanding. Never syncs to the public site."
|
|
717
|
+
action={<NewButton onClick={() => setEditing("new")}>New invoice</NewButton>}
|
|
718
|
+
/>
|
|
719
|
+
|
|
720
|
+
<div className="grid gap-4 sm:grid-cols-3">
|
|
721
|
+
<Stat label="Paid" value={money(totals.paid)} />
|
|
722
|
+
<Stat label="Outstanding" value={money(totals.outstanding)} />
|
|
723
|
+
<Stat label="Invoices" value={String(invoices.length)} />
|
|
724
|
+
</div>
|
|
725
|
+
|
|
726
|
+
<Card title="All invoices" count={invoices.length}>
|
|
727
|
+
{invoices.length === 0 ? (
|
|
728
|
+
<Empty>No invoices yet.</Empty>
|
|
729
|
+
) : (
|
|
730
|
+
<ul className="divide-y divide-zinc-100">
|
|
731
|
+
{invoices.map((inv) => (
|
|
732
|
+
<li key={inv.id} className="flex items-center gap-3 px-4 py-3.5">
|
|
733
|
+
<button
|
|
734
|
+
type="button"
|
|
735
|
+
onClick={() => setViewing(inv)}
|
|
736
|
+
className="min-w-0 flex-1 text-left"
|
|
737
|
+
title="View invoice"
|
|
738
|
+
>
|
|
739
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
740
|
+
<span className="font-mono text-[12.5px] font-medium text-zinc-900">{inv.number}</span>
|
|
741
|
+
<span className="text-[14px] font-medium text-zinc-900">{money(inv.amountCents)}</span>
|
|
742
|
+
<Pill kind="invoice" status={inv.status} />
|
|
743
|
+
</div>
|
|
744
|
+
<div className="mt-0.5 truncate text-[12.5px] text-zinc-500">
|
|
745
|
+
{inv.clientName}
|
|
746
|
+
{inv.projectTitle ? ` · ${inv.projectTitle}` : ""}
|
|
747
|
+
{inv.dueAt ? ` · due ${inv.dueAt}` : ""}
|
|
748
|
+
</div>
|
|
749
|
+
</button>
|
|
750
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
751
|
+
<select
|
|
752
|
+
value={inv.status}
|
|
753
|
+
disabled={busyId === inv.id}
|
|
754
|
+
onChange={(e) => setStatus(inv.id, e.target.value)}
|
|
755
|
+
aria-label="Status"
|
|
756
|
+
className="h-8 rounded-md border border-zinc-300 bg-white px-2 text-[12px] text-zinc-600 outline-none focus:border-brand"
|
|
757
|
+
>
|
|
758
|
+
<option value="draft">Draft</option>
|
|
759
|
+
<option value="sent">Sent</option>
|
|
760
|
+
<option value="paid">Paid</option>
|
|
761
|
+
<option value="overdue">Overdue</option>
|
|
762
|
+
</select>
|
|
763
|
+
<button type="button" onClick={() => setViewing(inv)} className="rounded-md px-2.5 py-1 text-[12.5px] font-medium text-zinc-600 hover:bg-zinc-100">
|
|
764
|
+
View
|
|
765
|
+
</button>
|
|
766
|
+
<button type="button" onClick={() => setEditing(inv)} className="rounded-md px-2.5 py-1 text-[12.5px] font-medium text-zinc-600 hover:bg-zinc-100">
|
|
767
|
+
Edit
|
|
768
|
+
</button>
|
|
769
|
+
<button
|
|
770
|
+
type="button"
|
|
771
|
+
disabled={busyId === inv.id}
|
|
772
|
+
onClick={() => remove(inv)}
|
|
773
|
+
className="rounded-md px-2 py-1 text-[12.5px] text-zinc-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
|
|
774
|
+
title="Delete"
|
|
775
|
+
>
|
|
776
|
+
✕
|
|
777
|
+
</button>
|
|
778
|
+
</div>
|
|
779
|
+
</li>
|
|
780
|
+
))}
|
|
781
|
+
</ul>
|
|
782
|
+
)}
|
|
783
|
+
</Card>
|
|
784
|
+
|
|
785
|
+
{viewing ? (
|
|
786
|
+
<InvoiceView
|
|
787
|
+
inv={viewing}
|
|
788
|
+
client={clientFor(viewing)}
|
|
789
|
+
onClose={() => setViewing(null)}
|
|
790
|
+
onEdit={() => { setEditing(viewing); setViewing(null); }}
|
|
791
|
+
/>
|
|
792
|
+
) : null}
|
|
793
|
+
|
|
794
|
+
{editing ? (
|
|
795
|
+
<InvoiceForm
|
|
796
|
+
initial={editing === "new" ? null : editing}
|
|
797
|
+
clients={clients}
|
|
798
|
+
projects={projects}
|
|
799
|
+
nextNumber={`INV-${String(invoices.length + 1).padStart(3, "0")}`}
|
|
800
|
+
onClose={() => setEditing(null)}
|
|
801
|
+
onSaved={load}
|
|
802
|
+
/>
|
|
803
|
+
) : null}
|
|
804
|
+
</div>
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Assemble the PDF payload from an invoice + its client + the studio config,
|
|
809
|
+
// then download it. The renderer is imported lazily here so it never lands in
|
|
810
|
+
// SSR or the initial bundle.
|
|
811
|
+
async function downloadInvoicePdf(inv: InvoiceRow, client?: ClientRow) {
|
|
812
|
+
const { buildInvoiceBlob } = await import("@/lib/invoice-pdf");
|
|
813
|
+
const blob = await buildInvoiceBlob({
|
|
814
|
+
number: inv.number,
|
|
815
|
+
status: inv.status,
|
|
816
|
+
issuedAt: inv.issuedAt,
|
|
817
|
+
dueAt: inv.dueAt,
|
|
818
|
+
notes: inv.notes,
|
|
819
|
+
projectTitle: inv.projectTitle,
|
|
820
|
+
items: parseLineItems(inv.lineItems),
|
|
821
|
+
amountCents: inv.amountCents,
|
|
822
|
+
billTo: { name: inv.clientName, company: client?.company, email: client?.email },
|
|
823
|
+
studio: {
|
|
824
|
+
name: siteConfig.brand.name,
|
|
825
|
+
addressLines: siteConfig.billing.addressLines,
|
|
826
|
+
paymentTerms: siteConfig.billing.paymentTerms,
|
|
827
|
+
footerNote: siteConfig.billing.footerNote,
|
|
828
|
+
brandColor: siteConfig.colors.brand,
|
|
829
|
+
},
|
|
830
|
+
});
|
|
831
|
+
const url = URL.createObjectURL(blob);
|
|
832
|
+
const a = document.createElement("a");
|
|
833
|
+
a.href = url;
|
|
834
|
+
a.download = `${inv.number}.pdf`;
|
|
835
|
+
document.body.appendChild(a);
|
|
836
|
+
a.click();
|
|
837
|
+
a.remove();
|
|
838
|
+
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// The full invoice document — an on-screen preview that mirrors the PDF, with
|
|
842
|
+
// Download PDF + Edit in the toolbar.
|
|
843
|
+
function InvoiceView({
|
|
844
|
+
inv,
|
|
845
|
+
client,
|
|
846
|
+
onClose,
|
|
847
|
+
onEdit,
|
|
848
|
+
}: {
|
|
849
|
+
inv: InvoiceRow;
|
|
850
|
+
client?: ClientRow;
|
|
851
|
+
onClose: () => void;
|
|
852
|
+
onEdit: () => void;
|
|
853
|
+
}) {
|
|
854
|
+
const [downloading, setDownloading] = useState(false);
|
|
855
|
+
const items = parseLineItems(inv.lineItems);
|
|
856
|
+
const rows = items.length > 0 ? items : [{ description: "Services", quantity: 1, unitCents: inv.amountCents }];
|
|
857
|
+
const { brand, billing } = siteConfig;
|
|
858
|
+
|
|
859
|
+
useEffect(() => {
|
|
860
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
|
861
|
+
window.addEventListener("keydown", onKey);
|
|
862
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
863
|
+
}, [onClose]);
|
|
864
|
+
|
|
865
|
+
async function download() {
|
|
866
|
+
setDownloading(true);
|
|
867
|
+
try {
|
|
868
|
+
await downloadInvoicePdf(inv, client);
|
|
869
|
+
} catch {
|
|
870
|
+
/* generation is best-effort; the dashboard stays usable */
|
|
871
|
+
} finally {
|
|
872
|
+
setDownloading(false);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
return (
|
|
877
|
+
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-4 sm:p-8" onMouseDown={onClose}>
|
|
878
|
+
<div
|
|
879
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
880
|
+
className="my-auto w-full max-w-2xl overflow-hidden rounded-2xl bg-white shadow-[0_24px_64px_-16px_rgba(0,0,0,0.4)]"
|
|
881
|
+
>
|
|
882
|
+
{/* Toolbar */}
|
|
883
|
+
<div className="flex items-center justify-between border-b border-zinc-100 px-5 py-3">
|
|
884
|
+
<h2 className="font-mono text-[14px] font-semibold text-zinc-900">{inv.number}</h2>
|
|
885
|
+
<div className="flex items-center gap-2">
|
|
886
|
+
<button type="button" onClick={onEdit} className="rounded-lg px-3 py-1.5 text-[13px] font-medium text-zinc-600 hover:bg-zinc-100">
|
|
887
|
+
Edit
|
|
888
|
+
</button>
|
|
889
|
+
<button
|
|
890
|
+
type="button"
|
|
891
|
+
onClick={download}
|
|
892
|
+
disabled={downloading}
|
|
893
|
+
className="rounded-lg bg-brand px-3.5 py-1.5 text-[13px] font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50"
|
|
894
|
+
>
|
|
895
|
+
{downloading ? "Preparing…" : "Download PDF"}
|
|
896
|
+
</button>
|
|
897
|
+
<button type="button" onClick={onClose} className="rounded-md px-2 py-1 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700">✕</button>
|
|
898
|
+
</div>
|
|
899
|
+
</div>
|
|
900
|
+
|
|
901
|
+
{/* Document */}
|
|
902
|
+
<div className="max-h-[75vh] overflow-y-auto px-8 py-8 text-[13px] text-zinc-700">
|
|
903
|
+
<div className="flex items-start justify-between gap-6">
|
|
904
|
+
<div>
|
|
905
|
+
<div className="text-[17px] font-semibold text-zinc-900">{brand.name}</div>
|
|
906
|
+
{billing.addressLines.map((l, i) => (
|
|
907
|
+
<div key={i} className="text-[12px] text-zinc-500">{l}</div>
|
|
908
|
+
))}
|
|
909
|
+
</div>
|
|
910
|
+
<div className="text-right">
|
|
911
|
+
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.16em] text-brand">Invoice</div>
|
|
912
|
+
<div className="font-mono text-[14px] font-semibold text-zinc-900">{inv.number}</div>
|
|
913
|
+
<div className="mt-1"><Pill kind="invoice" status={inv.status} /></div>
|
|
914
|
+
</div>
|
|
915
|
+
</div>
|
|
916
|
+
|
|
917
|
+
<div className="my-6 border-t border-zinc-200" />
|
|
918
|
+
|
|
919
|
+
<div className="flex items-start justify-between gap-6">
|
|
920
|
+
<div>
|
|
921
|
+
<div className="text-[10px] font-semibold uppercase tracking-[0.12em] text-zinc-400">Bill to</div>
|
|
922
|
+
<div className="mt-1 font-medium text-zinc-900">{inv.clientName}</div>
|
|
923
|
+
{client?.company ? <div className="text-[12px] text-zinc-500">{client.company}</div> : null}
|
|
924
|
+
{client?.email ? <div className="text-[12px] text-zinc-500">{client.email}</div> : null}
|
|
925
|
+
</div>
|
|
926
|
+
<div className="w-48 space-y-1 text-[12px]">
|
|
927
|
+
{inv.projectTitle ? <MetaLine label="Project" value={inv.projectTitle} /> : null}
|
|
928
|
+
{inv.issuedAt ? <MetaLine label="Issued" value={inv.issuedAt} /> : null}
|
|
929
|
+
{inv.dueAt ? <MetaLine label="Due" value={inv.dueAt} /> : null}
|
|
930
|
+
<MetaLine label="Terms" value={billing.paymentTerms} />
|
|
931
|
+
</div>
|
|
932
|
+
</div>
|
|
933
|
+
|
|
934
|
+
<table className="mt-7 w-full text-[12.5px]">
|
|
935
|
+
<thead>
|
|
936
|
+
<tr className="border-b border-zinc-900 text-[10px] uppercase tracking-wide text-zinc-500">
|
|
937
|
+
<th className="py-1.5 text-left font-semibold">Description</th>
|
|
938
|
+
<th className="py-1.5 text-right font-semibold">Qty</th>
|
|
939
|
+
<th className="py-1.5 text-right font-semibold">Unit</th>
|
|
940
|
+
<th className="py-1.5 text-right font-semibold">Amount</th>
|
|
941
|
+
</tr>
|
|
942
|
+
</thead>
|
|
943
|
+
<tbody>
|
|
944
|
+
{rows.map((it, i) => (
|
|
945
|
+
<tr key={i} className="border-b border-zinc-100">
|
|
946
|
+
<td className="py-2 pr-3">{it.description}</td>
|
|
947
|
+
<td className="py-2 text-right tabular-nums">{it.quantity}</td>
|
|
948
|
+
<td className="py-2 text-right tabular-nums">{money(it.unitCents)}</td>
|
|
949
|
+
<td className="py-2 text-right tabular-nums">{money(Math.round(it.quantity * it.unitCents))}</td>
|
|
950
|
+
</tr>
|
|
951
|
+
))}
|
|
952
|
+
</tbody>
|
|
953
|
+
</table>
|
|
954
|
+
|
|
955
|
+
<div className="mt-4 flex justify-end">
|
|
956
|
+
<div className="flex w-56 items-baseline justify-between">
|
|
957
|
+
<span className="text-[13px] font-semibold text-zinc-900">Total due</span>
|
|
958
|
+
<span className="text-[18px] font-semibold tabular-nums text-brand">{money(inv.amountCents)}</span>
|
|
959
|
+
</div>
|
|
960
|
+
</div>
|
|
961
|
+
|
|
962
|
+
{inv.notes ? (
|
|
963
|
+
<div className="mt-8">
|
|
964
|
+
<div className="text-[10px] font-semibold uppercase tracking-[0.12em] text-zinc-400">Notes</div>
|
|
965
|
+
<p className="mt-1 whitespace-pre-wrap text-[12.5px] text-zinc-600">{inv.notes}</p>
|
|
966
|
+
</div>
|
|
967
|
+
) : null}
|
|
968
|
+
|
|
969
|
+
<p className="mt-10 text-center text-[11px] text-zinc-400">{billing.footerNote}</p>
|
|
970
|
+
</div>
|
|
971
|
+
</div>
|
|
972
|
+
</div>
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function MetaLine({ label, value }: { label: string; value: string }) {
|
|
977
|
+
return (
|
|
978
|
+
<div className="flex items-baseline justify-between gap-3">
|
|
979
|
+
<span className="text-zinc-400">{label}</span>
|
|
980
|
+
<span className="font-medium text-zinc-700">{value}</span>
|
|
981
|
+
</div>
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// One editable line in the invoice form — strings while editing, converted to
|
|
986
|
+
// { quantity, unitCents } on save.
|
|
987
|
+
interface ItemDraft {
|
|
988
|
+
description: string;
|
|
989
|
+
quantity: string;
|
|
990
|
+
unit: string; // dollars, as typed
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function initialItems(initial: InvoiceRow | null): ItemDraft[] {
|
|
994
|
+
const parsed = initial ? parseLineItems(initial.lineItems) : [];
|
|
995
|
+
if (parsed.length > 0) {
|
|
996
|
+
return parsed.map((it) => ({
|
|
997
|
+
description: it.description,
|
|
998
|
+
quantity: String(it.quantity),
|
|
999
|
+
unit: (it.unitCents / 100).toFixed(2),
|
|
1000
|
+
}));
|
|
1001
|
+
}
|
|
1002
|
+
// A legacy invoice (amount, no items) seeds one line at its total.
|
|
1003
|
+
if (initial && initial.amountCents > 0) {
|
|
1004
|
+
return [{ description: "Services", quantity: "1", unit: (initial.amountCents / 100).toFixed(2) }];
|
|
1005
|
+
}
|
|
1006
|
+
return [{ description: "", quantity: "1", unit: "" }];
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function draftToItem(d: ItemDraft): InvoiceLineItem {
|
|
1010
|
+
return {
|
|
1011
|
+
description: d.description.trim(),
|
|
1012
|
+
quantity: Number(d.quantity) || 0,
|
|
1013
|
+
unitCents: Math.max(0, Math.round((parseFloat(d.unit) || 0) * 100)),
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function InvoiceForm({
|
|
1018
|
+
initial,
|
|
1019
|
+
clients,
|
|
1020
|
+
projects,
|
|
1021
|
+
nextNumber,
|
|
1022
|
+
onClose,
|
|
1023
|
+
onSaved,
|
|
1024
|
+
}: {
|
|
1025
|
+
initial: InvoiceRow | null;
|
|
1026
|
+
clients: ClientRow[];
|
|
1027
|
+
projects: ProjectRow[];
|
|
1028
|
+
nextNumber: string;
|
|
1029
|
+
onClose: () => void;
|
|
1030
|
+
onSaved: () => Promise<void>;
|
|
1031
|
+
}) {
|
|
1032
|
+
const [f, setF] = useState({
|
|
1033
|
+
number: initial?.number ?? nextNumber,
|
|
1034
|
+
clientId: initial?.clientId ?? clients[0]?.id ?? "",
|
|
1035
|
+
projectId: initial?.projectId ?? "",
|
|
1036
|
+
status: initial?.status ?? "draft",
|
|
1037
|
+
issuedAt: initial?.issuedAt ?? "",
|
|
1038
|
+
dueAt: initial?.dueAt ?? "",
|
|
1039
|
+
notes: initial?.notes ?? "",
|
|
1040
|
+
});
|
|
1041
|
+
const [items, setItems] = useState<ItemDraft[]>(() => initialItems(initial));
|
|
1042
|
+
const [saving, setSaving] = useState(false);
|
|
1043
|
+
const [err, setErr] = useState<string | null>(null);
|
|
1044
|
+
const set = <K extends keyof typeof f>(k: K, v: (typeof f)[K]) => setF((s) => ({ ...s, [k]: v }) as typeof f);
|
|
1045
|
+
|
|
1046
|
+
const setItem = (i: number, patch: Partial<ItemDraft>) =>
|
|
1047
|
+
setItems((arr) => arr.map((it, idx) => (idx === i ? { ...it, ...patch } : it)));
|
|
1048
|
+
const addItem = () => setItems((arr) => [...arr, { description: "", quantity: "1", unit: "" }]);
|
|
1049
|
+
const removeItem = (i: number) => setItems((arr) => (arr.length > 1 ? arr.filter((_, idx) => idx !== i) : arr));
|
|
1050
|
+
|
|
1051
|
+
const totalCents = lineItemsTotal(items.map(draftToItem));
|
|
1052
|
+
|
|
1053
|
+
async function save() {
|
|
1054
|
+
if (!f.clientId) { setErr("Pick a client."); return; }
|
|
1055
|
+
if (!f.number.trim()) { setErr("An invoice number is required."); return; }
|
|
1056
|
+
const lineItems = items.map(draftToItem).filter((it) => it.description.length > 0 || it.unitCents > 0);
|
|
1057
|
+
setSaving(true);
|
|
1058
|
+
setErr(null);
|
|
1059
|
+
try {
|
|
1060
|
+
await callFn("upsertInvoice", {
|
|
1061
|
+
id: initial?.id,
|
|
1062
|
+
number: f.number.trim(),
|
|
1063
|
+
clientId: f.clientId,
|
|
1064
|
+
projectId: f.projectId || undefined,
|
|
1065
|
+
lineItems,
|
|
1066
|
+
amountCents: totalCents,
|
|
1067
|
+
status: f.status,
|
|
1068
|
+
issuedAt: f.issuedAt || undefined,
|
|
1069
|
+
dueAt: f.dueAt || undefined,
|
|
1070
|
+
notes: f.notes.trim() || undefined,
|
|
1071
|
+
});
|
|
1072
|
+
await onSaved();
|
|
1073
|
+
onClose();
|
|
1074
|
+
} catch (e) {
|
|
1075
|
+
setErr(e instanceof Error ? e.message : "Couldn't save — try again.");
|
|
1076
|
+
setSaving(false);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const noClients = clients.length === 0;
|
|
1081
|
+
|
|
1082
|
+
return (
|
|
1083
|
+
<Modal title={initial ? "Edit invoice" : "New invoice"} onClose={onClose} onSubmit={save} saving={saving} error={err}>
|
|
1084
|
+
{noClients ? (
|
|
1085
|
+
<p className="rounded-lg bg-amber-50 px-3 py-2 text-[13px] text-amber-800">
|
|
1086
|
+
Add a client first — an invoice needs someone to bill.
|
|
1087
|
+
</p>
|
|
1088
|
+
) : null}
|
|
1089
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
1090
|
+
<Field label="Number" required>
|
|
1091
|
+
<input value={f.number} onChange={(e) => set("number", e.target.value)} className={inputCls + " font-mono"} />
|
|
1092
|
+
</Field>
|
|
1093
|
+
<Field label="Client" required>
|
|
1094
|
+
<select value={f.clientId} onChange={(e) => set("clientId", e.target.value)} className={inputCls} disabled={noClients}>
|
|
1095
|
+
{noClients ? <option value="">No clients yet</option> : null}
|
|
1096
|
+
{clients.map((c) => (
|
|
1097
|
+
<option key={c.id} value={c.id}>
|
|
1098
|
+
{c.name}{c.company ? ` · ${c.company}` : ""}
|
|
1099
|
+
</option>
|
|
1100
|
+
))}
|
|
1101
|
+
</select>
|
|
1102
|
+
</Field>
|
|
1103
|
+
</div>
|
|
1104
|
+
|
|
1105
|
+
{/* Line items */}
|
|
1106
|
+
<div>
|
|
1107
|
+
<span className="mb-1 block text-[12.5px] font-medium text-zinc-600">Line items</span>
|
|
1108
|
+
<div className="space-y-2">
|
|
1109
|
+
{items.map((it, i) => (
|
|
1110
|
+
<div key={i} className="flex items-center gap-2">
|
|
1111
|
+
<input
|
|
1112
|
+
value={it.description}
|
|
1113
|
+
onChange={(e) => setItem(i, { description: e.target.value })}
|
|
1114
|
+
placeholder="Description"
|
|
1115
|
+
className={inputBase + " min-w-0 flex-1"}
|
|
1116
|
+
/>
|
|
1117
|
+
<input
|
|
1118
|
+
type="number"
|
|
1119
|
+
min={0}
|
|
1120
|
+
value={it.quantity}
|
|
1121
|
+
onChange={(e) => setItem(i, { quantity: e.target.value })}
|
|
1122
|
+
aria-label="Quantity"
|
|
1123
|
+
title="Quantity"
|
|
1124
|
+
className={inputBase + " w-14 shrink-0 text-center tabular-nums"}
|
|
1125
|
+
/>
|
|
1126
|
+
<div className="relative w-28 shrink-0">
|
|
1127
|
+
<span className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-[13px] text-zinc-400">$</span>
|
|
1128
|
+
<input
|
|
1129
|
+
type="number"
|
|
1130
|
+
min={0}
|
|
1131
|
+
step="0.01"
|
|
1132
|
+
value={it.unit}
|
|
1133
|
+
onChange={(e) => setItem(i, { unit: e.target.value })}
|
|
1134
|
+
placeholder="0.00"
|
|
1135
|
+
aria-label="Unit price"
|
|
1136
|
+
title="Unit price"
|
|
1137
|
+
className={inputBase + " w-full pl-6 text-right tabular-nums"}
|
|
1138
|
+
/>
|
|
1139
|
+
</div>
|
|
1140
|
+
<button
|
|
1141
|
+
type="button"
|
|
1142
|
+
onClick={() => removeItem(i)}
|
|
1143
|
+
disabled={items.length === 1}
|
|
1144
|
+
className="shrink-0 rounded-md px-2 py-1 text-[13px] text-zinc-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-30"
|
|
1145
|
+
title="Remove line"
|
|
1146
|
+
>
|
|
1147
|
+
✕
|
|
1148
|
+
</button>
|
|
1149
|
+
</div>
|
|
1150
|
+
))}
|
|
1151
|
+
</div>
|
|
1152
|
+
<div className="mt-2 flex items-center justify-between">
|
|
1153
|
+
<button type="button" onClick={addItem} className="text-[12.5px] font-medium text-brand hover:underline">
|
|
1154
|
+
+ Add line
|
|
1155
|
+
</button>
|
|
1156
|
+
<span className="text-[13px] text-zinc-500">
|
|
1157
|
+
Total <span className="font-semibold tabular-nums text-zinc-900">{money(totalCents)}</span>
|
|
1158
|
+
</span>
|
|
1159
|
+
</div>
|
|
1160
|
+
</div>
|
|
1161
|
+
|
|
1162
|
+
<Field label="Project" hint="Optional — ties the bill to a case study">
|
|
1163
|
+
<select value={f.projectId} onChange={(e) => set("projectId", e.target.value)} className={inputCls}>
|
|
1164
|
+
<option value="">None</option>
|
|
1165
|
+
{projects.map((p) => (
|
|
1166
|
+
<option key={p.id} value={p.id}>{p.title}</option>
|
|
1167
|
+
))}
|
|
1168
|
+
</select>
|
|
1169
|
+
</Field>
|
|
1170
|
+
<div className="grid gap-3 sm:grid-cols-3">
|
|
1171
|
+
<Field label="Status">
|
|
1172
|
+
<select value={f.status} onChange={(e) => set("status", e.target.value)} className={inputCls}>
|
|
1173
|
+
<option value="draft">Draft</option>
|
|
1174
|
+
<option value="sent">Sent</option>
|
|
1175
|
+
<option value="paid">Paid</option>
|
|
1176
|
+
<option value="overdue">Overdue</option>
|
|
1177
|
+
</select>
|
|
1178
|
+
</Field>
|
|
1179
|
+
<Field label="Issued">
|
|
1180
|
+
<input type="date" value={f.issuedAt ?? ""} onChange={(e) => set("issuedAt", e.target.value)} className={inputCls} />
|
|
1181
|
+
</Field>
|
|
1182
|
+
<Field label="Due">
|
|
1183
|
+
<input type="date" value={f.dueAt ?? ""} onChange={(e) => set("dueAt", e.target.value)} className={inputCls} />
|
|
1184
|
+
</Field>
|
|
1185
|
+
</div>
|
|
1186
|
+
<Field label="Notes">
|
|
1187
|
+
<textarea value={f.notes} onChange={(e) => set("notes", e.target.value)} rows={2} className={inputCls + " resize-none py-2"} />
|
|
1188
|
+
</Field>
|
|
1189
|
+
</Modal>
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/* ============================== PRIMITIVES ============================= */
|
|
1194
|
+
|
|
1195
|
+
function PanelHead({ title, sub, action }: { title: string; sub: string; action?: React.ReactNode }) {
|
|
1196
|
+
return (
|
|
1197
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
1198
|
+
<div>
|
|
1199
|
+
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
|
|
1200
|
+
<p className="mt-1 max-w-xl text-sm text-zinc-500">{sub}</p>
|
|
1201
|
+
</div>
|
|
1202
|
+
{action}
|
|
1203
|
+
</div>
|
|
1204
|
+
);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function NewButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
|
|
1208
|
+
return (
|
|
1209
|
+
<button
|
|
1210
|
+
type="button"
|
|
1211
|
+
onClick={onClick}
|
|
1212
|
+
className="shrink-0 rounded-lg bg-zinc-900 px-3.5 py-2 text-[13px] font-medium text-white transition-colors hover:bg-zinc-700"
|
|
1213
|
+
>
|
|
1214
|
+
+ {children}
|
|
1215
|
+
</button>
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function Card({ title, count, children }: { title: string; count: number; children: React.ReactNode }) {
|
|
1220
|
+
return (
|
|
1221
|
+
<div className="rounded-xl border border-zinc-200 bg-white">
|
|
1222
|
+
<div className="border-b border-zinc-100 px-4 py-3 text-sm font-semibold text-zinc-900">
|
|
1223
|
+
{title} <span className="font-normal text-zinc-400">({count})</span>
|
|
1224
|
+
</div>
|
|
1225
|
+
{children}
|
|
1226
|
+
</div>
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function Empty({ children }: { children: React.ReactNode }) {
|
|
1231
|
+
return <p className="p-8 text-center text-sm text-zinc-500">{children}</p>;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function Stat({ label, value, hint }: { label: string; value: string; hint?: string }) {
|
|
1235
|
+
return (
|
|
1236
|
+
<div className="rounded-xl border border-zinc-200 bg-white p-4">
|
|
1237
|
+
<div className="text-[11px] font-medium uppercase tracking-wide text-zinc-400">{label}</div>
|
|
1238
|
+
<div className="mt-1 text-2xl font-semibold tabular-nums text-zinc-900">{value}</div>
|
|
1239
|
+
{hint ? <div className="mt-0.5 text-[12px] text-zinc-400">{hint}</div> : null}
|
|
1240
|
+
</div>
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// One pill component for every status across the dashboard, tone-mapped by kind.
|
|
1245
|
+
function Pill({ kind, status }: { kind: "inquiry" | "client" | "invoice"; status: string }) {
|
|
1246
|
+
const tones: Record<string, string> = {
|
|
1247
|
+
// inquiry
|
|
1248
|
+
new: "bg-amber-50 text-amber-700",
|
|
1249
|
+
booked: "bg-green-50 text-green-700",
|
|
1250
|
+
declined: "bg-zinc-100 text-zinc-400",
|
|
1251
|
+
// client
|
|
1252
|
+
prospect: "bg-blue-50 text-blue-700",
|
|
1253
|
+
active: "bg-green-50 text-green-700",
|
|
1254
|
+
past: "bg-zinc-100 text-zinc-500",
|
|
1255
|
+
// invoice
|
|
1256
|
+
draft: "bg-zinc-100 text-zinc-500",
|
|
1257
|
+
sent: "bg-blue-50 text-blue-700",
|
|
1258
|
+
paid: "bg-green-50 text-green-700",
|
|
1259
|
+
overdue: "bg-red-50 text-red-700",
|
|
1260
|
+
};
|
|
1261
|
+
const label = kind === "inquiry" && status === "new" ? "new lead" : status;
|
|
1262
|
+
return (
|
|
1263
|
+
<span className={"rounded-full px-2 py-0.5 text-[10px] font-medium capitalize " + (tones[status] ?? "bg-zinc-100 text-zinc-500")}>
|
|
1264
|
+
{label}
|
|
1265
|
+
</span>
|
|
1266
|
+
);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function IconToggle({
|
|
1270
|
+
on,
|
|
1271
|
+
disabled,
|
|
1272
|
+
title,
|
|
1273
|
+
onClick,
|
|
1274
|
+
children,
|
|
1275
|
+
}: {
|
|
1276
|
+
on: boolean;
|
|
1277
|
+
disabled?: boolean;
|
|
1278
|
+
title: string;
|
|
1279
|
+
onClick: () => void;
|
|
1280
|
+
children: React.ReactNode;
|
|
1281
|
+
}) {
|
|
1282
|
+
return (
|
|
1283
|
+
<button
|
|
1284
|
+
type="button"
|
|
1285
|
+
disabled={disabled}
|
|
1286
|
+
title={title}
|
|
1287
|
+
onClick={onClick}
|
|
1288
|
+
className={
|
|
1289
|
+
"rounded-md px-2 py-1 text-[13px] leading-none transition-colors disabled:opacity-50 " +
|
|
1290
|
+
(on ? "text-amber-500 hover:bg-amber-50" : "text-zinc-300 hover:bg-zinc-100 hover:text-zinc-500")
|
|
1291
|
+
}
|
|
1292
|
+
>
|
|
1293
|
+
{children}
|
|
1294
|
+
</button>
|
|
1295
|
+
);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
function Switch({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
|
|
1299
|
+
return (
|
|
1300
|
+
<button type="button" onClick={() => onChange(!checked)} className="flex items-center gap-2 text-[13px] text-zinc-700">
|
|
1301
|
+
<span
|
|
1302
|
+
className={
|
|
1303
|
+
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors " +
|
|
1304
|
+
(checked ? "bg-brand" : "bg-zinc-300")
|
|
1305
|
+
}
|
|
1306
|
+
>
|
|
1307
|
+
<span className={"inline-block size-4 transform rounded-full bg-white transition-transform " + (checked ? "translate-x-4" : "translate-x-0.5")} />
|
|
1308
|
+
</span>
|
|
1309
|
+
{label}
|
|
1310
|
+
</button>
|
|
1311
|
+
);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// A simple centered modal with a sticky footer. Submits on the footer button or
|
|
1315
|
+
// Enter; closes on the backdrop, the ✕, or Escape.
|
|
1316
|
+
function Modal({
|
|
1317
|
+
title,
|
|
1318
|
+
onClose,
|
|
1319
|
+
onSubmit,
|
|
1320
|
+
saving,
|
|
1321
|
+
error,
|
|
1322
|
+
children,
|
|
1323
|
+
}: {
|
|
1324
|
+
title: string;
|
|
1325
|
+
onClose: () => void;
|
|
1326
|
+
onSubmit: () => void;
|
|
1327
|
+
saving: boolean;
|
|
1328
|
+
error: string | null;
|
|
1329
|
+
children: React.ReactNode;
|
|
1330
|
+
}) {
|
|
1331
|
+
useEffect(() => {
|
|
1332
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
|
1333
|
+
window.addEventListener("keydown", onKey);
|
|
1334
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
1335
|
+
}, [onClose]);
|
|
1336
|
+
|
|
1337
|
+
return (
|
|
1338
|
+
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/30 p-4 sm:p-8" onMouseDown={onClose}>
|
|
1339
|
+
<form
|
|
1340
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
1341
|
+
onSubmit={(e) => { e.preventDefault(); onSubmit(); }}
|
|
1342
|
+
className="my-auto w-full max-w-lg rounded-2xl bg-white shadow-[0_24px_64px_-16px_rgba(0,0,0,0.35)]"
|
|
1343
|
+
>
|
|
1344
|
+
<div className="flex items-center justify-between border-b border-zinc-100 px-5 py-3.5">
|
|
1345
|
+
<h2 className="text-[15px] font-semibold text-zinc-900">{title}</h2>
|
|
1346
|
+
<button type="button" onClick={onClose} className="rounded-md px-2 py-1 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700">✕</button>
|
|
1347
|
+
</div>
|
|
1348
|
+
<div className="max-h-[70vh] space-y-3.5 overflow-y-auto px-5 py-4">{children}</div>
|
|
1349
|
+
<div className="flex items-center justify-between gap-3 border-t border-zinc-100 px-5 py-3.5">
|
|
1350
|
+
<span className="text-[12.5px] text-red-600">{error}</span>
|
|
1351
|
+
<div className="flex items-center gap-2">
|
|
1352
|
+
<button type="button" onClick={onClose} className="rounded-lg px-3.5 py-2 text-[13px] font-medium text-zinc-600 hover:bg-zinc-100">
|
|
1353
|
+
Cancel
|
|
1354
|
+
</button>
|
|
1355
|
+
<button type="submit" disabled={saving} className="rounded-lg bg-brand px-4 py-2 text-[13px] font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50">
|
|
1356
|
+
{saving ? "Saving…" : "Save"}
|
|
1357
|
+
</button>
|
|
1358
|
+
</div>
|
|
1359
|
+
</div>
|
|
1360
|
+
</form>
|
|
1361
|
+
</div>
|
|
1362
|
+
);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
function Field({ label, hint, required, children }: { label: string; hint?: string; required?: boolean; children: React.ReactNode }) {
|
|
1366
|
+
return (
|
|
1367
|
+
<label className="block">
|
|
1368
|
+
<span className="mb-1 block text-[12.5px] font-medium text-zinc-600">
|
|
1369
|
+
{label}
|
|
1370
|
+
{required ? <span className="text-brand"> *</span> : null}
|
|
1371
|
+
{hint ? <span className="ml-1 font-normal text-zinc-400">— {hint}</span> : null}
|
|
1372
|
+
</span>
|
|
1373
|
+
{children}
|
|
1374
|
+
</label>
|
|
1375
|
+
);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// Base field styles WITHOUT a width, so flex rows (the invoice line items) can
|
|
1379
|
+
// size each input themselves. `inputCls` is the full-width variant for the
|
|
1380
|
+
// stacked form fields.
|
|
1381
|
+
const inputBase =
|
|
1382
|
+
"h-9 rounded-lg border border-zinc-300 bg-white px-3 text-[14px] text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-brand focus:ring-2 focus:ring-brand/20";
|
|
1383
|
+
const inputCls = inputBase + " w-full";
|
|
1384
|
+
|
|
1385
|
+
function OwnerOnly({ email }: { email: string }) {
|
|
1386
|
+
return (
|
|
1387
|
+
<div className="rounded-xl border border-dashed border-zinc-300 px-6 py-12 text-center">
|
|
1388
|
+
<h1 className="text-lg font-semibold">This dashboard is owner-only</h1>
|
|
1389
|
+
<p className="mx-auto mt-2 max-w-md text-sm text-zinc-500">
|
|
1390
|
+
You're signed in as <span className="font-medium text-zinc-700">{email || "this account"}</span>.
|
|
1391
|
+
Only the studio owner can see the back-office. Set{" "}
|
|
1392
|
+
<code className="rounded bg-zinc-100 px-1.5 py-0.5 text-[12px]">PYLON_OWNER_EMAIL={email || "you@studio.com"}</code>{" "}
|
|
1393
|
+
in your <code className="rounded bg-zinc-100 px-1.5 py-0.5 text-[12px]">.env</code>, restart, and reload —
|
|
1394
|
+
or sign in with the owner account.
|
|
1395
|
+
</p>
|
|
1396
|
+
</div>
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
export function UserMenu({ email }: { email: string }) {
|
|
1401
|
+
const { signOut } = useAuth();
|
|
1402
|
+
const initial = (email.trim()[0] || "?").toUpperCase();
|
|
1403
|
+
async function onSignOut() {
|
|
1404
|
+
await signOut();
|
|
1405
|
+
window.location.assign("/");
|
|
1406
|
+
}
|
|
1407
|
+
return (
|
|
1408
|
+
<details className="group relative">
|
|
1409
|
+
<summary className="flex size-8 cursor-pointer select-none list-none items-center justify-center rounded-full bg-zinc-900 text-[12px] font-semibold text-white marker:hidden [&::-webkit-details-marker]:hidden">
|
|
1410
|
+
{initial}
|
|
1411
|
+
</summary>
|
|
1412
|
+
<div className="absolute right-0 top-full z-40 mt-2 w-56 overflow-hidden rounded-xl border border-zinc-200 bg-white py-1 shadow-[0_16px_48px_-16px_rgba(0,0,0,0.25)]">
|
|
1413
|
+
<div className="border-b border-zinc-100 px-3 py-2">
|
|
1414
|
+
<div className="truncate text-[13px] font-medium text-zinc-900">{email || "Signed in"}</div>
|
|
1415
|
+
</div>
|
|
1416
|
+
<button
|
|
1417
|
+
type="button"
|
|
1418
|
+
onClick={onSignOut}
|
|
1419
|
+
className="flex w-full items-center px-3 py-2 text-left text-[13px] text-zinc-700 transition-colors hover:bg-zinc-50"
|
|
1420
|
+
>
|
|
1421
|
+
Sign out
|
|
1422
|
+
</button>
|
|
1423
|
+
</div>
|
|
1424
|
+
</details>
|
|
1425
|
+
);
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function Skeleton({ compact }: { compact?: boolean }) {
|
|
1429
|
+
return (
|
|
1430
|
+
<div className="space-y-8">
|
|
1431
|
+
{!compact ? <div className="h-6 w-28 animate-pulse rounded bg-zinc-100" /> : null}
|
|
1432
|
+
<div className="grid gap-4 sm:grid-cols-3">
|
|
1433
|
+
{[0, 1, 2].map((i) => (
|
|
1434
|
+
<div key={i} className="h-20 animate-pulse rounded-xl bg-zinc-100" />
|
|
1435
|
+
))}
|
|
1436
|
+
</div>
|
|
1437
|
+
<div className="h-48 animate-pulse rounded-xl bg-zinc-100" />
|
|
1438
|
+
</div>
|
|
1439
|
+
);
|
|
1440
|
+
}
|