fix:bug
This commit is contained in:
16
frontend/app/(main)/layout.tsx
Normal file
16
frontend/app/(main)/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
export default function MainLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<AppSidebar />
|
||||
<main className="flex-1 overflow-auto bg-background">{children}</main>
|
||||
<Toaster position="top-right" richColors closeButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
frontend/app/(main)/page.tsx
Normal file
19
frontend/app/(main)/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="flex min-h-full flex-col items-center justify-center p-8">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Ops-Core</h1>
|
||||
<p className="mt-2 text-muted-foreground">自动化办公与业务中台</p>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<Button asChild>
|
||||
<Link href="/workspace">需求与方案工作台</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/finance">财务归档</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
271
frontend/app/(main)/workspace/page.tsx
Normal file
271
frontend/app/(main)/workspace/page.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Wand2, Save, FileSpreadsheet, FileDown, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
customersApi,
|
||||
projectsApi,
|
||||
downloadFile,
|
||||
downloadFileAsBlob,
|
||||
type CustomerRead,
|
||||
type QuoteGenerateResponse,
|
||||
} from "@/lib/api/client";
|
||||
|
||||
export default function WorkspacePage() {
|
||||
const [customers, setCustomers] = useState<CustomerRead[]>([]);
|
||||
const [customerId, setCustomerId] = useState<string>("");
|
||||
const [rawText, setRawText] = useState("");
|
||||
const [solutionMd, setSolutionMd] = useState("");
|
||||
const [projectId, setProjectId] = useState<number | null>(null);
|
||||
const [lastQuote, setLastQuote] = useState<QuoteGenerateResponse | null>(null);
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [generatingQuote, setGeneratingQuote] = useState(false);
|
||||
|
||||
const loadCustomers = useCallback(async () => {
|
||||
try {
|
||||
const list = await customersApi.list();
|
||||
setCustomers(list);
|
||||
if (list.length > 0 && !customerId) setCustomerId(String(list[0].id));
|
||||
} catch (e) {
|
||||
toast.error("加载客户列表失败");
|
||||
}
|
||||
}, [customerId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadCustomers();
|
||||
}, [loadCustomers]);
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!customerId || !rawText.trim()) {
|
||||
toast.error("请选择客户并输入原始需求");
|
||||
return;
|
||||
}
|
||||
setAnalyzing(true);
|
||||
try {
|
||||
const res = await projectsApi.analyze({
|
||||
customer_id: Number(customerId),
|
||||
raw_text: rawText.trim(),
|
||||
});
|
||||
setSolutionMd(res.ai_solution_md);
|
||||
setProjectId(res.project_id);
|
||||
setLastQuote(null);
|
||||
toast.success("方案已生成,可在右侧编辑");
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "AI 解析失败";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveToArchive = async () => {
|
||||
if (projectId == null) {
|
||||
toast.error("请先进行 AI 解析");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await projectsApi.update(projectId, { ai_solution_md: solutionMd });
|
||||
toast.success("已保存到项目档案");
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "保存失败";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDraftQuote = async () => {
|
||||
if (projectId == null) {
|
||||
toast.error("请先进行 AI 解析并保存");
|
||||
return;
|
||||
}
|
||||
setGeneratingQuote(true);
|
||||
try {
|
||||
const res = await projectsApi.generateQuote(projectId);
|
||||
setLastQuote(res);
|
||||
toast.success("报价单已生成");
|
||||
downloadFile(res.excel_path, `quote_project_${projectId}.xlsx`);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "生成报价失败";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setGeneratingQuote(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportPdf = async () => {
|
||||
if (lastQuote?.pdf_path) {
|
||||
downloadFile(lastQuote.pdf_path, `quote_project_${projectId}.pdf`);
|
||||
toast.success("PDF 已下载");
|
||||
return;
|
||||
}
|
||||
if (projectId == null) {
|
||||
toast.error("请先进行 AI 解析");
|
||||
return;
|
||||
}
|
||||
setGeneratingQuote(true);
|
||||
try {
|
||||
const res = await projectsApi.generateQuote(projectId);
|
||||
setLastQuote(res);
|
||||
await downloadFileAsBlob(res.pdf_path, `quote_project_${projectId}.pdf`);
|
||||
toast.success("PDF 已下载");
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "生成 PDF 失败";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setGeneratingQuote(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Left Panel — 40% */}
|
||||
<div className="flex w-[40%] flex-col border-r">
|
||||
<Card className="rounded-none border-0 border-b h-full flex flex-col">
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-base flex items-center justify-between">
|
||||
原始需求
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAnalyze}
|
||||
disabled={analyzing || !rawText.trim() || !customerId}
|
||||
>
|
||||
{analyzing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Wand2 className="h-4 w-4" />
|
||||
)}
|
||||
<span className="ml-1.5">AI 解析</span>
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col gap-2 min-h-0 pt-0">
|
||||
<div className="space-y-1.5">
|
||||
<Label>客户</Label>
|
||||
<Select value={customerId} onValueChange={setCustomerId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择客户" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{customers.map((c) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<Label>微信/客户需求文本</Label>
|
||||
<textarea
|
||||
className="mt-1.5 flex-1 min-h-[200px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 resize-none"
|
||||
placeholder="粘贴或输入客户原始需求..."
|
||||
value={rawText}
|
||||
onChange={(e) => setRawText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Panel — 60% */}
|
||||
<div className="flex flex-1 flex-col min-w-0">
|
||||
<Card className="rounded-none border-0 h-full flex flex-col">
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-base">方案草稿(可编辑)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<Tabs defaultValue="edit" className="flex-1 flex flex-col min-h-0">
|
||||
<TabsList>
|
||||
<TabsTrigger value="edit">编辑</TabsTrigger>
|
||||
<TabsTrigger value="preview">预览</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="edit" className="flex-1 min-h-0 mt-2">
|
||||
<textarea
|
||||
className="h-full min-h-[320px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 resize-none"
|
||||
placeholder="AI 解析后将在此显示 Markdown,可直接修改..."
|
||||
value={solutionMd}
|
||||
onChange={(e) => setSolutionMd(e.target.value)}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="preview" className="flex-1 min-h-0 mt-2 overflow-auto">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none p-2">
|
||||
{solutionMd ? (
|
||||
<ReactMarkdown>{solutionMd}</ReactMarkdown>
|
||||
) : (
|
||||
<p className="text-muted-foreground">暂无内容</p>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Action Bar */}
|
||||
<div className="flex items-center gap-3 border-t bg-card px-4 py-3 shadow-[0_-2px 10px rgba(0,0,0,0.05)]">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSaveToArchive}
|
||||
disabled={saving || projectId == null}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
<span className="ml-1.5">保存到档案</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDraftQuote}
|
||||
disabled={generatingQuote || projectId == null}
|
||||
>
|
||||
{generatingQuote ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileSpreadsheet className="h-4 w-4" />
|
||||
)}
|
||||
<span className="ml-1.5">生成报价单 (Excel)</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExportPdf}
|
||||
disabled={generatingQuote || projectId == null}
|
||||
>
|
||||
{generatingQuote ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileDown className="h-4 w-4" />
|
||||
)}
|
||||
<span className="ml-1.5">导出 PDF</span>
|
||||
</Button>
|
||||
{projectId != null && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
当前项目 #{projectId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
frontend/app/globals.css
Normal file
59
frontend/app/globals.css
Normal file
@@ -0,0 +1,59 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
19
frontend/app/layout.tsx
Normal file
19
frontend/app/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Ops-Core | 自动化办公与业务中台",
|
||||
description: "Monolithic automation & business ops platform",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className="antialiased min-h-screen">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
21
frontend/components.json
Normal file
21
frontend/components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
81
frontend/components/app-sidebar.tsx
Normal file
81
frontend/components/app-sidebar.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
FileText,
|
||||
FolderArchive,
|
||||
Settings,
|
||||
Building2,
|
||||
Globe,
|
||||
PiggyBank,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HistoricalReferences } from "@/components/historical-references";
|
||||
|
||||
const QUICK_LINKS = [
|
||||
{ label: "国家税务总局门户", href: "https://www.chinatax.gov.cn", icon: Building2 },
|
||||
{ label: "电子税务局", href: "https://etax.chinatax.gov.cn", icon: Globe },
|
||||
{ label: "公积金管理中心", href: "https://www.12329.com.cn", icon: PiggyBank },
|
||||
];
|
||||
|
||||
export function AppSidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
const nav = [
|
||||
{ href: "/workspace", label: "需求与方案", icon: FileText },
|
||||
{ href: "/finance", label: "财务归档", icon: FolderArchive },
|
||||
{ href: "/settings", label: "设置", icon: Settings },
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="flex w-56 flex-col border-r bg-card text-card-foreground">
|
||||
<div className="p-4">
|
||||
<Link href="/" className="font-semibold text-foreground">
|
||||
Ops-Core
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">自动化办公中台</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<nav className="flex flex-1 flex-col gap-1 p-2">
|
||||
{nav.map((item) => (
|
||||
<Link key={item.href} href={item.href}>
|
||||
<Button
|
||||
variant={pathname === item.href ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<item.icon className="mr-2 h-4 w-4" />
|
||||
{item.label}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<Separator />
|
||||
<div className="p-2">
|
||||
<p className="text-xs font-medium text-muted-foreground px-2 mb-2">
|
||||
快捷门户
|
||||
</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{QUICK_LINKS.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<link.icon className="h-4 w-4 shrink-0" />
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{pathname === "/workspace" && <HistoricalReferences />}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
111
frontend/components/historical-references.tsx
Normal file
111
frontend/components/historical-references.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { listProjects, type ProjectRead } from "@/lib/api/client";
|
||||
import { Copy, Search, FileText } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function HistoricalReferences() {
|
||||
const [projects, setProjects] = useState<ProjectRead[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await listProjects();
|
||||
setProjects(data);
|
||||
} catch (e) {
|
||||
toast.error("加载历史项目失败");
|
||||
setProjects([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const filtered = search.trim()
|
||||
? projects.filter(
|
||||
(p) =>
|
||||
p.raw_requirement.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(p.ai_solution_md || "").toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: projects.slice(0, 10);
|
||||
|
||||
const copySnippet = (text: string, label: string) => {
|
||||
if (!text) return;
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success(`已复制 ${label}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t p-2">
|
||||
<p className="text-xs font-medium text-muted-foreground px-2 mb-2 flex items-center gap-1">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
历史参考
|
||||
</p>
|
||||
<div className="relative mb-2">
|
||||
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索项目..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-8 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto space-y-1">
|
||||
{loading ? (
|
||||
<p className="text-xs text-muted-foreground px-2">加载中...</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground px-2">暂无项目</p>
|
||||
) : (
|
||||
filtered.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={cn(
|
||||
"rounded border bg-background/50 p-2 text-xs space-y-1"
|
||||
)}
|
||||
>
|
||||
<p className="font-medium text-foreground line-clamp-1">
|
||||
项目 #{p.id}
|
||||
</p>
|
||||
<p className="text-muted-foreground line-clamp-2">
|
||||
{p.raw_requirement.slice(0, 80)}…
|
||||
</p>
|
||||
<div className="flex gap-1 pt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs"
|
||||
onClick={() =>
|
||||
copySnippet(p.raw_requirement, "原始需求")
|
||||
}
|
||||
>
|
||||
<Copy className="h-3 w-3 mr-0.5" />
|
||||
需求
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs"
|
||||
onClick={() =>
|
||||
copySnippet(p.ai_solution_md || "", "方案")
|
||||
}
|
||||
>
|
||||
<Copy className="h-3 w-3 mr-0.5" />
|
||||
方案
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
frontend/components/ui/button.tsx
Normal file
52
frontend/components/ui/button.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
78
frontend/components/ui/card.tsx
Normal file
78
frontend/components/ui/card.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
24
frontend/components/ui/input.tsx
Normal file
24
frontend/components/ui/input.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
25
frontend/components/ui/label.tsx
Normal file
25
frontend/components/ui/label.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
157
frontend/components/ui/select.tsx
Normal file
157
frontend/components/ui/select.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
};
|
||||
30
frontend/components/ui/separator.tsx
Normal file
30
frontend/components/ui/separator.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
54
frontend/components/ui/tabs.tsx
Normal file
54
frontend/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
247
frontend/lib/api/client.ts
Normal file
247
frontend/lib/api/client.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* API client for the FastAPI backend (Ops-Core).
|
||||
* Base URL is read from NEXT_PUBLIC_API_BASE (default http://localhost:8000).
|
||||
*/
|
||||
|
||||
const getBase = (): string => {
|
||||
if (typeof window !== "undefined") {
|
||||
return process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
|
||||
}
|
||||
return process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
|
||||
};
|
||||
|
||||
export const apiBase = getBase;
|
||||
|
||||
// --------------- Types (mirror FastAPI schemas) ---------------
|
||||
|
||||
export interface CustomerRead {
|
||||
id: number;
|
||||
name: string;
|
||||
contact_info: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CustomerCreate {
|
||||
name: string;
|
||||
contact_info?: string | null;
|
||||
}
|
||||
|
||||
export interface CustomerUpdate {
|
||||
name?: string;
|
||||
contact_info?: string | null;
|
||||
}
|
||||
|
||||
export interface RequirementAnalyzeRequest {
|
||||
customer_id: number;
|
||||
raw_text: string;
|
||||
}
|
||||
|
||||
export interface RequirementAnalyzeResponse {
|
||||
project_id: number;
|
||||
ai_solution_md: string;
|
||||
ai_solution_json: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface QuoteGenerateResponse {
|
||||
quote_id: number;
|
||||
project_id: number;
|
||||
total_amount: number;
|
||||
excel_path: string;
|
||||
pdf_path: string;
|
||||
}
|
||||
|
||||
export interface ContractGenerateRequest {
|
||||
delivery_date: string;
|
||||
extra_placeholders?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ContractGenerateResponse {
|
||||
project_id: number;
|
||||
contract_path: string;
|
||||
}
|
||||
|
||||
export interface FinanceSyncResult {
|
||||
id: number;
|
||||
month: string;
|
||||
type: string;
|
||||
file_name: string;
|
||||
file_path: string;
|
||||
}
|
||||
|
||||
export interface FinanceSyncResponse {
|
||||
items: FinanceSyncResult[];
|
||||
}
|
||||
|
||||
export interface ProjectRead {
|
||||
id: number;
|
||||
customer_id: number;
|
||||
raw_requirement: string;
|
||||
ai_solution_md: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
customer?: CustomerRead;
|
||||
quotes?: { id: number; total_amount: string; file_path: string }[];
|
||||
}
|
||||
|
||||
export interface ProjectUpdate {
|
||||
ai_solution_md?: string | null;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
// --------------- Request helper ---------------
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit & { params?: Record<string, string> } = {}
|
||||
): Promise<T> {
|
||||
const { params, ...init } = options;
|
||||
const base = apiBase();
|
||||
let url = `${base}${path}`;
|
||||
if (params && Object.keys(params).length > 0) {
|
||||
const search = new URLSearchParams(params).toString();
|
||||
url += (path.includes("?") ? "&" : "?") + search;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
let detail = text;
|
||||
try {
|
||||
const j = JSON.parse(text);
|
||||
detail = j.detail ?? text;
|
||||
} catch {
|
||||
// keep text
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
const contentType = res.headers.get("content-type");
|
||||
if (contentType?.includes("application/json")) {
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
/** Download a file from the backend and return a Blob (for Excel/PDF/Zip). */
|
||||
export async function downloadBlob(path: string): Promise<Blob> {
|
||||
const base = apiBase();
|
||||
const url = path.startsWith("http") ? path : `${base}${path.startsWith("/") ? "" : "/"}${path}`;
|
||||
const res = await fetch(url, { credentials: "include" });
|
||||
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger download in the browser (Excel, PDF, or Zip).
|
||||
* path: backend-returned path e.g. "data/quotes/quote_project_1.xlsx" -> we request /data/quotes/quote_project_1.xlsx
|
||||
*/
|
||||
export function downloadFile(path: string, filename: string): void {
|
||||
const base = apiBase();
|
||||
const normalized = path.startsWith("/") ? path : `/${path}`;
|
||||
const url = `${base}${normalized}`;
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.rel = "noopener noreferrer";
|
||||
a.target = "_blank";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
/** Same as downloadFile but using fetch + Blob for consistent CORS and filename control. */
|
||||
export async function downloadFileAsBlob(
|
||||
path: string,
|
||||
filename: string,
|
||||
mime?: string
|
||||
): Promise<void> {
|
||||
const blob = await downloadBlob(path);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// --------------- Customers ---------------
|
||||
|
||||
export const customersApi = {
|
||||
list: () => request<CustomerRead[]>("/customers/"),
|
||||
get: (id: number) => request<CustomerRead>(`/customers/${id}`),
|
||||
create: (body: CustomerCreate) =>
|
||||
request<CustomerRead>("/customers/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
update: (id: number, body: CustomerUpdate) =>
|
||||
request<CustomerRead>(`/customers/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
delete: (id: number) =>
|
||||
request<void>(`/customers/${id}`, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
// --------------- Projects ---------------
|
||||
|
||||
export const projectsApi = {
|
||||
analyze: (body: RequirementAnalyzeRequest) =>
|
||||
request<RequirementAnalyzeResponse>("/projects/analyze", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
update: (projectId: number, body: ProjectUpdate) =>
|
||||
request<ProjectRead>(`/projects/${projectId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
generateQuote: (projectId: number) =>
|
||||
request<QuoteGenerateResponse>(`/projects/${projectId}/generate_quote`, {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
generateContract: (projectId: number, body: ContractGenerateRequest) =>
|
||||
request<ContractGenerateResponse>(
|
||||
`/projects/${projectId}/generate_contract`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
export async function listProjects(): Promise<ProjectRead[]> {
|
||||
return request<ProjectRead[]>("/projects/");
|
||||
}
|
||||
|
||||
export async function getProject(projectId: number): Promise<ProjectRead> {
|
||||
return request<ProjectRead>(`/projects/${projectId}`);
|
||||
}
|
||||
|
||||
// --------------- Finance ---------------
|
||||
|
||||
export const financeApi = {
|
||||
sync: () =>
|
||||
request<FinanceSyncResponse>("/finance/sync", { method: "POST" }),
|
||||
|
||||
/** Returns the URL to download the zip (or use downloadFile with the path). */
|
||||
getDownloadUrl: (month: string) => `${apiBase()}/finance/download/${month}`,
|
||||
|
||||
/** Download monthly zip as blob and trigger save. */
|
||||
downloadMonth: async (month: string): Promise<void> => {
|
||||
const path = `/finance/download/${month}`;
|
||||
await downloadFileAsBlob(path, `finance_${month}.zip`, "application/zip");
|
||||
},
|
||||
};
|
||||
6
frontend/lib/utils.ts
Normal file
6
frontend/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
5
frontend/next-env.d.ts
vendored
Normal file
5
frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
10
frontend/next.config.mjs
Normal file
10
frontend/next.config.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
// Proxy API to FastAPI in dev if needed (optional; frontend can call API_BASE directly)
|
||||
async rewrites() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
46
frontend/package.json
Normal file
46
frontend/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "ops-core-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.18",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"axios": "^1.7.7",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.454.0",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"react-markdown": "^9.0.1",
|
||||
"sonner": "^1.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "14.2.18",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"@tailwindcss/typography": "^0.5.15"
|
||||
}
|
||||
}
|
||||
9
frontend/postcss.config.mjs
Normal file
9
frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,9 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
57
frontend/tailwind.config.ts
Normal file
57
frontend/tailwind.config.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
|
||||
};
|
||||
|
||||
export default config;
|
||||
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user