Apply .gitignore cleanup

This commit is contained in:
admin
2025-10-05 09:21:04 +07:00
parent d2a7a3e478
commit 3448594bc5
3515 changed files with 20582 additions and 1501448 deletions

16
frontend/.dockerignore Executable file → Normal file
View File

@@ -1,9 +1,9 @@
node_modules
npm-debug.log
.next
.next/cache
.git
.gitignore
.DS_Store
.env*.local
node_modules
npm-debug.log
.next
.next/cache
.git
.gitignore
.DS_Store
.env*.local
*.logs

14
frontend/api/health/route.js Executable file → Normal file
View File

@@ -1,7 +1,7 @@
// File: frontend/api/health/route.js
export async function GET() {
return new Response(JSON.stringify({ status: 'ok', service: 'frontend', ts: Date.now() }), {
headers: { 'content-type': 'application/json' },
status: 200,
});
}
// File: frontend/api/health/route.js
export async function GET() {
return new Response(JSON.stringify({ status: 'ok', service: 'frontend', ts: Date.now() }), {
headers: { 'content-type': 'application/json' },
status: 200,
});
}

0
frontend/app/(auth)/login/page copy.jsx Executable file → Normal file
View File

0
frontend/app/(auth)/login/page.jsx Executable file → Normal file
View File

4
frontend/app/(protected)/contracts-volumes/page.jsx Executable file → Normal file
View File

@@ -1,3 +1,3 @@
export default function Page(){
return <div className="rounded-2xl p-5 bg-white">Contracts & Volumes โครงขอม/กเอกสาร</div>;
export default function Page(){
return <div className="rounded-2xl p-5 bg-white">Contracts & Volumes โครงขอม/กเอกสาร</div>;
}

4
frontend/app/(protected)/correspondences/new/page.jsx Executable file → Normal file
View File

@@ -1,3 +1,3 @@
export default function Page(){
return <div className="rounded-2xl p-5 bg-white">ฟอรมบนทกหนงสอสอสาร</div>;
export default function Page(){
return <div className="rounded-2xl p-5 bg-white">ฟอรมบนทกหนงสอสอสาร</div>;
}

4
frontend/app/(protected)/correspondences/page.jsx Executable file → Normal file
View File

@@ -1,3 +1,3 @@
export default function Page(){
return <div className="rounded-2xl p-5 bg-white">Correspondences list/table</div>;
export default function Page(){
return <div className="rounded-2xl p-5 bg-white">Correspondences list/table</div>;
}

308
frontend/app/(protected)/dashboard/page.jsx Executable file → Normal file
View File

@@ -1,155 +1,155 @@
// frontend/app/(protected)/dashboard/page.jsx
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Activity, File, FilePlus, ArrowRight, BellDot, Settings } from 'lucide-react';
import api from '@/lib/api';
import { useAuth } from '@/lib/auth';
import { can } from '@/lib/rbac';
export default function DashboardPage() {
const { user } = useAuth();
const [stats, setStats] = useState(null);
const [myTasks, setMyTasks] = useState([]);
const [recentActivity, setRecentActivity] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
// เรียก API ที่จำเป็นสำหรับ Dashboard พร้อมกัน
// หมายเหตุ: Endpoint เหล่านี้อาจจะต้องสร้างเพิ่มเติมในฝั่ง Backend
const [statsRes, tasksRes, activityRes] = await Promise.all([
api.get('/dashboard/stats').catch(e => ({ data: { totalDocuments: 0, newThisWeek: 0, pendingRfas: 0 }})), // สมมติ endpoint สำหรับ KPI
api.get('/dashboard/my-tasks').catch(e => ({ data: [] })), // สมมติ endpoint สำหรับ Action Items
api.get('/dashboard/recent-activity').catch(e => ({ data: [] })), // สมมติ endpoint สำหรับ Recent Activity
]);
setStats(statsRes.data);
setMyTasks(tasksRes.data);
setRecentActivity(activityRes.data);
} catch (error) {
console.error("Failed to fetch dashboard data:", error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
if (loading) {
// อาจจะใช้ Skeleton UI ที่นี่เพื่อให้ UX ดีขึ้น
return <div className="text-center">Loading Dashboard...</div>;
}
return (
<div className="space-y-6">
{/* ส่วน Header และ Quick Access Buttons */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold">Dashboard</h1>
<p className="text-muted-foreground">Welcome back, {user?.username || 'User'}!</p>
</div>
<div className="flex gap-2">
<Button asChild>
<Link href="/correspondences/new">
<FilePlus className="w-4 h-4 mr-2" /> New Correspondence
</Link>
</Button>
{/* ปุ่ม Admin Settings จะแสดงเมื่อมีสิทธิ์ 'manage_users' เท่านั้น */}
{user && can(user, 'manage_users') && (
<Button asChild variant="outline">
<Link href="/admin/users">
<Settings className="w-4 h-4 mr-2" /> Admin Settings
</Link>
</Button>
)}
</div>
</div>
{/* ส่วน Key Metrics (KPIs) */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium">Total Documents</CardTitle>
<File className="w-4 h-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.totalDocuments || 0}</div>
<p className="text-xs text-muted-foreground">+{stats?.newThisWeek || 0} this week</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium">Pending RFAs</CardTitle>
<BellDot className="w-4 h-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.pendingRfas || 0}</div>
<p className="text-xs text-muted-foreground">Require your attention</p>
</CardContent>
</Card>
{/* สามารถเพิ่ม Card อื่นๆ ตามต้องการ */}
</div>
{/* ส่วน Action Items และ Recent Activity */}
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-3">
{/* Action Items */}
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>My Action Items</CardTitle>
<CardDescription>Tasks that require your immediate attention.</CardDescription>
</CardHeader>
<CardContent className="pl-6">
{myTasks && myTasks.length > 0 ? (
<ul className="space-y-4">
{myTasks.map(task => (
<li key={task.id} className="flex items-start">
<ArrowRight className="flex-shrink-0 w-4 h-4 mt-1 mr-3 text-primary" />
<Link href={task.link || '#'} className="text-sm hover:underline">
{task.title}
</Link>
</li>
))}
</ul>
) : (
<p className="text-sm italic text-muted-foreground">No pending tasks. You're all caught up!</p>
)}
</CardContent>
</Card>
{/* Recent Activity */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Recent Project Activity</CardTitle>
<CardDescription>Latest updates from the team.</CardDescription>
</CardHeader>
<CardContent>
{recentActivity && recentActivity.length > 0 ? (
<ul className="space-y-4">
{recentActivity.map(activity => (
<li key={activity.id} className="flex items-start">
<Activity className="flex-shrink-0 w-4 h-4 mt-1 mr-3 text-muted-foreground" />
<div>
<p className="text-sm" dangerouslySetInnerHTML={{ __html: activity.description }}></p>
<p className="text-xs text-muted-foreground">{new Date(activity.timestamp).toLocaleString()}</p>
</div>
</li>
))}
</ul>
) : (
<p className="text-sm italic text-muted-foreground">No recent activity.</p>
)}
</CardContent>
</Card>
</div>
</div>
);
// frontend/app/(protected)/dashboard/page.jsx
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Activity, File, FilePlus, ArrowRight, BellDot, Settings } from 'lucide-react';
import api from '@/lib/api';
import { useAuth } from '@/lib/auth';
import { can } from '@/lib/rbac';
export default function DashboardPage() {
const { user } = useAuth();
const [stats, setStats] = useState(null);
const [myTasks, setMyTasks] = useState([]);
const [recentActivity, setRecentActivity] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
// เรียก API ที่จำเป็นสำหรับ Dashboard พร้อมกัน
// หมายเหตุ: Endpoint เหล่านี้อาจจะต้องสร้างเพิ่มเติมในฝั่ง Backend
const [statsRes, tasksRes, activityRes] = await Promise.all([
api.get('/dashboard/stats').catch(e => ({ data: { totalDocuments: 0, newThisWeek: 0, pendingRfas: 0 }})), // สมมติ endpoint สำหรับ KPI
api.get('/dashboard/my-tasks').catch(e => ({ data: [] })), // สมมติ endpoint สำหรับ Action Items
api.get('/dashboard/recent-activity').catch(e => ({ data: [] })), // สมมติ endpoint สำหรับ Recent Activity
]);
setStats(statsRes.data);
setMyTasks(tasksRes.data);
setRecentActivity(activityRes.data);
} catch (error) {
console.error("Failed to fetch dashboard data:", error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
if (loading) {
// อาจจะใช้ Skeleton UI ที่นี่เพื่อให้ UX ดีขึ้น
return <div className="text-center">Loading Dashboard...</div>;
}
return (
<div className="space-y-6">
{/* ส่วน Header และ Quick Access Buttons */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold">Dashboard</h1>
<p className="text-muted-foreground">Welcome back, {user?.username || 'User'}!</p>
</div>
<div className="flex gap-2">
<Button asChild>
<Link href="/correspondences/new">
<FilePlus className="w-4 h-4 mr-2" /> New Correspondence
</Link>
</Button>
{/* ปุ่ม Admin Settings จะแสดงเมื่อมีสิทธิ์ 'manage_users' เท่านั้น */}
{user && can(user, 'manage_users') && (
<Button asChild variant="outline">
<Link href="/admin/users">
<Settings className="w-4 h-4 mr-2" /> Admin Settings
</Link>
</Button>
)}
</div>
</div>
{/* ส่วน Key Metrics (KPIs) */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium">Total Documents</CardTitle>
<File className="w-4 h-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.totalDocuments || 0}</div>
<p className="text-xs text-muted-foreground">+{stats?.newThisWeek || 0} this week</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium">Pending RFAs</CardTitle>
<BellDot className="w-4 h-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.pendingRfas || 0}</div>
<p className="text-xs text-muted-foreground">Require your attention</p>
</CardContent>
</Card>
{/* สามารถเพิ่ม Card อื่นๆ ตามต้องการ */}
</div>
{/* ส่วน Action Items และ Recent Activity */}
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-3">
{/* Action Items */}
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>My Action Items</CardTitle>
<CardDescription>Tasks that require your immediate attention.</CardDescription>
</CardHeader>
<CardContent className="pl-6">
{myTasks && myTasks.length > 0 ? (
<ul className="space-y-4">
{myTasks.map(task => (
<li key={task.id} className="flex items-start">
<ArrowRight className="flex-shrink-0 w-4 h-4 mt-1 mr-3 text-primary" />
<Link href={task.link || '#'} className="text-sm hover:underline">
{task.title}
</Link>
</li>
))}
</ul>
) : (
<p className="text-sm italic text-muted-foreground">No pending tasks. You're all caught up!</p>
)}
</CardContent>
</Card>
{/* Recent Activity */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Recent Project Activity</CardTitle>
<CardDescription>Latest updates from the team.</CardDescription>
</CardHeader>
<CardContent>
{recentActivity && recentActivity.length > 0 ? (
<ul className="space-y-4">
{recentActivity.map(activity => (
<li key={activity.id} className="flex items-start">
<Activity className="flex-shrink-0 w-4 h-4 mt-1 mr-3 text-muted-foreground" />
<div>
<p className="text-sm" dangerouslySetInnerHTML={{ __html: activity.description }}></p>
<p className="text-xs text-muted-foreground">{new Date(activity.timestamp).toLocaleString()}</p>
</div>
</li>
))}
</ul>
) : (
<p className="text-sm italic text-muted-foreground">No recent activity.</p>
)}
</CardContent>
</Card>
</div>
</div>
);
}

8
frontend/app/(protected)/drawings/page.jsx Executable file → Normal file
View File

@@ -1,5 +1,5 @@
import { getSession } from "@/lib/auth";
export default async function Page(){
const { user } = await getSession();
return <div className="rounded-2xl p-5 bg-white">Drawings list/table (อเชอม backend)</div>;
import { getSession } from "@/lib/auth";
export default async function Page(){
const { user } = await getSession();
return <div className="rounded-2xl p-5 bg-white">Drawings list/table (อเชอม backend)</div>;
}

4
frontend/app/(protected)/drawings/upload/page.jsx Executable file → Normal file
View File

@@ -1,3 +1,3 @@
export default function Page(){
return <div className="rounded-2xl p-5 bg-white">Upload Wizard 3 (เลอกไฟล Volume/Sub-cat Review)</div>;
export default function Page(){
return <div className="rounded-2xl p-5 bg-white">Upload Wizard 3 (เลอกไฟล Volume/Sub-cat Review)</div>;
}

4
frontend/app/(protected)/health/page.jsx Executable file → Normal file
View File

@@ -1,3 +1,3 @@
export default function Page(){
return <div className="rounded-2xl p-5 bg-white">Health แสดงสถานะ service (nginx, maria, n8n, postgres)</div>;
export default function Page(){
return <div className="rounded-2xl p-5 bg-white">Health แสดงสถานะ service (nginx, maria, n8n, postgres)</div>;
}

170
frontend/app/(protected)/layout.jsx Executable file → Normal file
View File

@@ -1,85 +1,85 @@
// frontend/app/(protected)/layout.jsx
import Link from "next/link";
import { redirect } from "next/navigation";
import { usePathname } from 'next/navigation';
import { cookies, headers } from "next/headers";
import { can } from "@/lib/rbac";
import { Home, FileText, Users, Settings } from 'lucide-react'; // เพิ่ม Users, Settings หรือไอคอนที่ต้องการ
export const metadata = { title: "DMS | Protected" };
const API_BASE = (process.env.NEXT_PUBLIC_API_BASE || "").replace(/\/$/, "");
async function fetchSessionFromAPI() {
const cookieStore = await cookies(); // ✅ ต้อง await
const cookieHeader = cookieStore.toString();
const hdrs = await headers(); // ✅ ต้อง await
const hostHdr = hdrs.get("host");
const protoHdr = hdrs.get("x-forwarded-proto") || "https";
const res = await fetch(`${API_BASE}/api/auth/me`, {
method: "GET",
headers: {
Cookie: cookieHeader,
"X-Forwarded-Host": hostHdr || "",
"X-Forwarded-Proto": protoHdr,
Accept: "application/json",
},
cache: "no-store",
});
if (!res.ok) return null;
try {
const data = await res.json();
return data?.ok ? data : null;
} catch {
return null;
}
}
export default async function ProtectedLayout({ children }) {
const session = await fetchSessionFromAPI();
if (!session) {
redirect("/login?next=/dashboard");
}
const { user } = session;
return (
<section className="grid grid-cols-12 gap-6 p-4 mx-auto max-w-7xl">
<aside className="col-span-12 lg:col-span-3 xl:col-span-3">
<div className="p-4 border rounded-3xl bg-white/70">
<div className="mb-3 text-sm">RBAC: <b>{user.role}</b></div>
<nav className="space-y-2">
<Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/dashboard">แดชบอร</Link>
<Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/drawings">Drawings</Link>
<Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/rfas">RFAs</Link>
<Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/transmittals">Transmittals</Link>
<Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/correspondences">Correspondences</Link>
<Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/contracts-volumes">Contracts & Volumes</Link>
<Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/reports">Reports</Link>
{can(user, "workflow:view") && <Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/workflow">Workflow (n8n)</Link>}
{can(user, "health:view") && <Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/health">Health</Link>}
{can(user, "users:manage") && <Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/users">ใช/บทบาท</Link>}
</nav>
</div>
</aside>
<main className="col-span-12 space-y-6 lg:col-span-9 xl:col-span-9">
<div className="flex items-center gap-2">
<div className="flex-1 text-lg font-semibold">Document Management System LCBP3 Phase 3</div>
{can(user, "admin:view") && <a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/admin">Admin</a>}
{can(user, "users:manage") && <a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/users">ใช/บทบาท</a>}
{can(user, "health:view") && <a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/health">Health</a>}
{can(user, "workflow:view") && <a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/workflow">Workflow</a>}
{can(user, "rfa:create") && <a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/rfas/new">+ RFA</a>}
{can(user, "drawing:upload") && <a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/drawings/upload">+ Upload Drawing</a>}
{can(user, "transmittal:create") && <a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/transmittals/new">+ Transmittal</a>}
{can(user, "correspondence:create") && <a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/correspondences/new">+ หนงสอสอสาร</a>}
</div>
{children}
</main>
</section>
);
}
// frontend/app/(protected)/layout.jsx
import Link from "next/link";
import { redirect } from "next/navigation";
import { usePathname } from 'next/navigation';
import { cookies, headers } from "next/headers";
import { can } from "@/lib/rbac";
import { Home, FileText, Users, Settings } from 'lucide-react'; // เพิ่ม Users, Settings หรือไอคอนที่ต้องการ
export const metadata = { title: "DMS | Protected" };
const API_BASE = (process.env.NEXT_PUBLIC_API_BASE || "").replace(/\/$/, "");
async function fetchSessionFromAPI() {
const cookieStore = await cookies(); // ✅ ต้อง await
const cookieHeader = cookieStore.toString();
const hdrs = await headers(); // ✅ ต้อง await
const hostHdr = hdrs.get("host");
const protoHdr = hdrs.get("x-forwarded-proto") || "https";
const res = await fetch(`${API_BASE}/api/auth/me`, {
method: "GET",
headers: {
Cookie: cookieHeader,
"X-Forwarded-Host": hostHdr || "",
"X-Forwarded-Proto": protoHdr,
Accept: "application/json",
},
cache: "no-store",
});
if (!res.ok) return null;
try {
const data = await res.json();
return data?.ok ? data : null;
} catch {
return null;
}
}
export default async function ProtectedLayout({ children }) {
const session = await fetchSessionFromAPI();
if (!session) {
redirect("/login?next=/dashboard");
}
const { user } = session;
return (
<section className="grid grid-cols-12 gap-6 p-4 mx-auto max-w-7xl">
<aside className="col-span-12 lg:col-span-3 xl:col-span-3">
<div className="p-4 border rounded-3xl bg-white/70">
<div className="mb-3 text-sm">RBAC: <b>{user.role}</b></div>
<nav className="space-y-2">
<Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/dashboard">แดชบอร</Link>
<Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/drawings">Drawings</Link>
<Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/rfas">RFAs</Link>
<Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/transmittals">Transmittals</Link>
<Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/correspondences">Correspondences</Link>
<Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/contracts-volumes">Contracts & Volumes</Link>
<Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/reports">Reports</Link>
{can(user, "workflow:view") && <Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/workflow">Workflow (n8n)</Link>}
{can(user, "health:view") && <Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/health">Health</Link>}
{can(user, "users:manage") && <Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/users">ใช/บทบาท</Link>}
</nav>
</div>
</aside>
<main className="col-span-12 space-y-6 lg:col-span-9 xl:col-span-9">
<div className="flex items-center gap-2">
<div className="flex-1 text-lg font-semibold">Document Management System LCBP3 Phase 3</div>
{can(user, "admin:view") && <a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/admin">Admin</a>}
{can(user, "users:manage") && <a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/users">ใช/บทบาท</a>}
{can(user, "health:view") && <a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/health">Health</a>}
{can(user, "workflow:view") && <a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/workflow">Workflow</a>}
{can(user, "rfa:create") && <a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/rfas/new">+ RFA</a>}
{can(user, "drawing:upload") && <a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/drawings/upload">+ Upload Drawing</a>}
{can(user, "transmittal:create") && <a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/transmittals/new">+ Transmittal</a>}
{can(user, "correspondence:create") && <a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/correspondences/new">+ หนงสอสอสาร</a>}
</div>
{children}
</main>
</section>
);
}

4
frontend/app/(protected)/reports/page.jsx Executable file → Normal file
View File

@@ -1,3 +1,3 @@
export default function Page(){
return <div className="rounded-2xl p-5 bg-white">Reports Export CSV/PDF</div>;
export default function Page(){
return <div className="rounded-2xl p-5 bg-white">Reports Export CSV/PDF</div>;
}

218
frontend/app/(protected)/rfas/new/page.jsx Executable file → Normal file
View File

@@ -1,110 +1,110 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
export default function RfaNew() {
const router = useRouter();
const [draftId, setDraftId] = React.useState(null);
const [saving, setSaving] = React.useState(false);
const [savedAt, setSavedAt] = React.useState(null);
const [error, setError] = React.useState("");
const [form, setForm] = React.useState({
title: "", code: "", discipline: "", due_date: "", description: ""
});
const [errs, setErrs] = React.useState({});
// simple validate (client)
const validate = (f) => {
const e = {};
if (!f.title?.trim()) e.title = "กรุณากรอกชื่อเรื่อง";
if (!f.due_date) e.due_date = "กรุณากำหนดวันที่ครบกำหนด";
return e;
};
// debounce autosave
const tRef = React.useRef(0);
React.useEffect(() => {
clearTimeout(tRef.current);
tRef.current = window.setTimeout(async () => {
const e = validate(form);
setErrs(e); // แสดง error ทันที (soft)
try {
setSaving(true);
if (!draftId) {
// create draft
const res = await api("/rfas", { method: "POST", body: { ...form, status: "draft" } });
setDraftId(res.id);
} else {
// update draft
await api(`/rfas/${draftId}`, { method: "PATCH", body: { ...form, status: "draft" } });
}
setSavedAt(new Date());
} catch (err) {
setError(err.message || "บันทึกฉบับร่างไม่สำเร็จ");
} finally {
setSaving(false);
}
}, 800);
return () => clearTimeout(tRef.current);
}, [form, draftId]);
const onSubmit = async (e) => {
e.preventDefault();
const eobj = validate(form);
setErrs(eobj);
if (Object.keys(eobj).length) return;
try {
setSaving(true);
const id = draftId
? (await api(`/rfas/${draftId}`, { method: "PATCH", body: { ...form, status: "submitted" } })).id || draftId
: (await api("/rfas", { method: "POST", body: { ...form, status: "submitted" } })).id;
router.replace(`/rfas`); // หรือไปหน้า detail `/rfas/${id}`
} catch (err) {
setError(err.message || "ส่งคำขอไม่สำเร็จ");
} finally {
setSaving(false);
}
};
return (
<form onSubmit={onSubmit} className="p-5 space-y-4 bg-white rounded-2xl">
<div className="text-lg font-semibold">สราง RFA</div>
{error && <div className="text-sm text-red-600">{error}</div>}
<div className="grid gap-3 md:grid-cols-2">
<div>
<label className="text-sm">อเรอง *</label>
<Input value={form.title} onChange={(e)=>setForm(f=>({...f, title:e.target.value}))}/>
{errs.title && <div className="mt-1 text-xs text-red-600">{errs.title}</div>}
</div>
<div>
<label className="text-sm">รห (าม)</label>
<Input value={form.code} onChange={(e)=>setForm(f=>({...f, code:e.target.value}))}/>
</div>
<div>
<label className="text-sm">สาขา/หมวด (Discipline)</label>
<Input value={form.discipline} onChange={(e)=>setForm(f=>({...f, discipline:e.target.value}))}/>
</div>
<div>
<label className="text-sm">กำหนดส *</label>
<input type="date" className="w-full p-2 border rounded-xl" value={form.due_date}
onChange={(e)=>setForm(f=>({...f, due_date:e.target.value}))}/>
{errs.due_date && <div className="mt-1 text-xs text-red-600">{errs.due_date}</div>}
</div>
</div>
<div>
<label className="text-sm">รายละเอยด</label>
<textarea rows={5} className="w-full p-2 border rounded-xl"
value={form.description} onChange={(e)=>setForm(f=>({...f, description:e.target.value}))}/>
</div>
<div className="flex items-center gap-3">
<Button type="submit" disabled={saving}>งเพอพจารณา</Button>
<span className="text-sm opacity-70">
{saving ? "กำลังบันทึก…" : savedAt ? `บันทึกล่าสุด ${savedAt.toLocaleTimeString()}` : "ยังไม่เคยบันทึก"}
</span>
</div>
</form>
);
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
export default function RfaNew() {
const router = useRouter();
const [draftId, setDraftId] = React.useState(null);
const [saving, setSaving] = React.useState(false);
const [savedAt, setSavedAt] = React.useState(null);
const [error, setError] = React.useState("");
const [form, setForm] = React.useState({
title: "", code: "", discipline: "", due_date: "", description: ""
});
const [errs, setErrs] = React.useState({});
// simple validate (client)
const validate = (f) => {
const e = {};
if (!f.title?.trim()) e.title = "กรุณากรอกชื่อเรื่อง";
if (!f.due_date) e.due_date = "กรุณากำหนดวันที่ครบกำหนด";
return e;
};
// debounce autosave
const tRef = React.useRef(0);
React.useEffect(() => {
clearTimeout(tRef.current);
tRef.current = window.setTimeout(async () => {
const e = validate(form);
setErrs(e); // แสดง error ทันที (soft)
try {
setSaving(true);
if (!draftId) {
// create draft
const res = await api("/rfas", { method: "POST", body: { ...form, status: "draft" } });
setDraftId(res.id);
} else {
// update draft
await api(`/rfas/${draftId}`, { method: "PATCH", body: { ...form, status: "draft" } });
}
setSavedAt(new Date());
} catch (err) {
setError(err.message || "บันทึกฉบับร่างไม่สำเร็จ");
} finally {
setSaving(false);
}
}, 800);
return () => clearTimeout(tRef.current);
}, [form, draftId]);
const onSubmit = async (e) => {
e.preventDefault();
const eobj = validate(form);
setErrs(eobj);
if (Object.keys(eobj).length) return;
try {
setSaving(true);
const id = draftId
? (await api(`/rfas/${draftId}`, { method: "PATCH", body: { ...form, status: "submitted" } })).id || draftId
: (await api("/rfas", { method: "POST", body: { ...form, status: "submitted" } })).id;
router.replace(`/rfas`); // หรือไปหน้า detail `/rfas/${id}`
} catch (err) {
setError(err.message || "ส่งคำขอไม่สำเร็จ");
} finally {
setSaving(false);
}
};
return (
<form onSubmit={onSubmit} className="p-5 space-y-4 bg-white rounded-2xl">
<div className="text-lg font-semibold">สราง RFA</div>
{error && <div className="text-sm text-red-600">{error}</div>}
<div className="grid gap-3 md:grid-cols-2">
<div>
<label className="text-sm">อเรอง *</label>
<Input value={form.title} onChange={(e)=>setForm(f=>({...f, title:e.target.value}))}/>
{errs.title && <div className="mt-1 text-xs text-red-600">{errs.title}</div>}
</div>
<div>
<label className="text-sm">รห (าม)</label>
<Input value={form.code} onChange={(e)=>setForm(f=>({...f, code:e.target.value}))}/>
</div>
<div>
<label className="text-sm">สาขา/หมวด (Discipline)</label>
<Input value={form.discipline} onChange={(e)=>setForm(f=>({...f, discipline:e.target.value}))}/>
</div>
<div>
<label className="text-sm">กำหนดส *</label>
<input type="date" className="w-full p-2 border rounded-xl" value={form.due_date}
onChange={(e)=>setForm(f=>({...f, due_date:e.target.value}))}/>
{errs.due_date && <div className="mt-1 text-xs text-red-600">{errs.due_date}</div>}
</div>
</div>
<div>
<label className="text-sm">รายละเอยด</label>
<textarea rows={5} className="w-full p-2 border rounded-xl"
value={form.description} onChange={(e)=>setForm(f=>({...f, description:e.target.value}))}/>
</div>
<div className="flex items-center gap-3">
<Button type="submit" disabled={saving}>งเพอพจารณา</Button>
<span className="text-sm opacity-70">
{saving ? "กำลังบันทึก…" : savedAt ? `บันทึกล่าสุด ${savedAt.toLocaleTimeString()}` : "ยังไม่เคยบันทึก"}
</span>
</div>
</form>
);
}

268
frontend/app/(protected)/rfas/page.jsx Executable file → Normal file
View File

@@ -1,135 +1,135 @@
"use client";
import React from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { apiGet } from "@/lib/api";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
export default function RFAsPage() {
const router = useRouter();
const pathname = usePathname();
const sp = useSearchParams();
// params from URL
const [q, setQ] = React.useState(sp.get("q") || "");
const status = sp.get("status") || "All";
const overdue = sp.get("overdue") === "1";
const page = Number(sp.get("page") || 1);
const pageSize = Number(sp.get("pageSize") || 20);
const sort = sp.get("sort") || "updated_at:desc";
const setParams = (patch) => {
const curr = Object.fromEntries(sp.entries());
const next = { ...curr, ...patch };
// normalize
if (!next.q) delete next.q;
if (!next.status || next.status === "All") delete next.status;
if (!next.overdue || next.overdue === "0") delete next.overdue;
if (!next.page || Number(next.page) === 1) delete next.page;
if (!next.pageSize || Number(next.pageSize) === 20) delete next.pageSize;
if (!next.sort || next.sort === "updated_at:desc") delete next.sort;
const usp = new URLSearchParams(next).toString();
router.replace(`${pathname}${usp ? `?${usp}` : ""}`);
};
const [rows, setRows] = React.useState([]);
const [total, setTotal] = React.useState(0);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState("");
// fetch whenever URL params change
React.useEffect(() => {
setLoading(true); setError("");
apiGet("/rfas", {
q, status: status !== "All" ? status : undefined,
overdue: overdue ? 1 : undefined, page, pageSize, sort
}).then((res) => {
// expected: { data: [...], page, pageSize, total }
setRows(res.data || []);
setTotal(res.total || 0);
}).catch((e) => {
setError(e.message || "โหลดข้อมูลไม่สำเร็จ");
}).finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sp]);
const pages = Math.max(1, Math.ceil(total / pageSize));
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Input
placeholder="ค้นหา (รหัส/ชื่อเรื่อง/ผู้รับผิดชอบ)"
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && setParams({ q, page: 1 })}
/>
<select
className="border rounded-xl p-2"
value={status}
onChange={(e) => setParams({ status: e.target.value, page: 1 })}
>
<option>All</option><option>Pending</option><option>Review</option><option>Approved</option><option>Closed</option>
</select>
<label className="text-sm flex items-center gap-2">
<input
type="checkbox"
checked={overdue}
onChange={(e) => setParams({ overdue: e.target.checked ? "1" : "0", page: 1 })}
/>
Overdue
</label>
<Button onClick={() => setParams({ q, page: 1 })}>นหา</Button>
</div>
<Card className="rounded-2xl border-0">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-white sticky top-0 border-b">
<tr className="text-left">
<th className="py-2 px-3">รห</th>
<th className="py-2 px-3">อเรอง</th>
<th className="py-2 px-3">สถานะ</th>
<th className="py-2 px-3">กำหนดส</th>
<th className="py-2 px-3">บผดชอบ</th>
</tr>
</thead>
<tbody>
{loading && <tr><td className="py-6 px-3" colSpan={5}>กำลงโหลด</td></tr>}
{error && !loading && <tr><td className="py-6 px-3 text-red-600" colSpan={5}>{error}</td></tr>}
{!loading && !error && rows.length === 0 && <tr><td className="py-6 px-3 opacity-70" colSpan={5}>ไมพบขอม</td></tr>}
{!loading && !error && rows.map((r) => (
<tr key={r.id} className="border-b hover:bg-gray-50">
<td className="py-2 px-3 font-mono">{r.code || r.id}</td>
<td className="py-2 px-3">{r.title}</td>
<td className="py-2 px-3">{r.status}</td>
<td className="py-2 px-3">{r.due_date || "—"}</td>
<td className="py-2 px-3">{r.owner_name || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between px-3 py-2 text-sm border-t">
<span>งหมด {total} รายการ</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => setParams({ page: Math.max(1, page - 1) })}
disabled={page <= 1}
>อนกล</Button>
<span>หน {page}/{pages}</span>
<Button
variant="outline"
onClick={() => setParams({ page: Math.min(pages, page + 1) })}
disabled={page >= pages}
>ดไป</Button>
</div>
</div>
</CardContent>
</Card>
</div>
);
"use client";
import React from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { apiGet } from "@/lib/api";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
export default function RFAsPage() {
const router = useRouter();
const pathname = usePathname();
const sp = useSearchParams();
// params from URL
const [q, setQ] = React.useState(sp.get("q") || "");
const status = sp.get("status") || "All";
const overdue = sp.get("overdue") === "1";
const page = Number(sp.get("page") || 1);
const pageSize = Number(sp.get("pageSize") || 20);
const sort = sp.get("sort") || "updated_at:desc";
const setParams = (patch) => {
const curr = Object.fromEntries(sp.entries());
const next = { ...curr, ...patch };
// normalize
if (!next.q) delete next.q;
if (!next.status || next.status === "All") delete next.status;
if (!next.overdue || next.overdue === "0") delete next.overdue;
if (!next.page || Number(next.page) === 1) delete next.page;
if (!next.pageSize || Number(next.pageSize) === 20) delete next.pageSize;
if (!next.sort || next.sort === "updated_at:desc") delete next.sort;
const usp = new URLSearchParams(next).toString();
router.replace(`${pathname}${usp ? `?${usp}` : ""}`);
};
const [rows, setRows] = React.useState([]);
const [total, setTotal] = React.useState(0);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState("");
// fetch whenever URL params change
React.useEffect(() => {
setLoading(true); setError("");
apiGet("/rfas", {
q, status: status !== "All" ? status : undefined,
overdue: overdue ? 1 : undefined, page, pageSize, sort
}).then((res) => {
// expected: { data: [...], page, pageSize, total }
setRows(res.data || []);
setTotal(res.total || 0);
}).catch((e) => {
setError(e.message || "โหลดข้อมูลไม่สำเร็จ");
}).finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sp]);
const pages = Math.max(1, Math.ceil(total / pageSize));
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Input
placeholder="ค้นหา (รหัส/ชื่อเรื่อง/ผู้รับผิดชอบ)"
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && setParams({ q, page: 1 })}
/>
<select
className="border rounded-xl p-2"
value={status}
onChange={(e) => setParams({ status: e.target.value, page: 1 })}
>
<option>All</option><option>Pending</option><option>Review</option><option>Approved</option><option>Closed</option>
</select>
<label className="text-sm flex items-center gap-2">
<input
type="checkbox"
checked={overdue}
onChange={(e) => setParams({ overdue: e.target.checked ? "1" : "0", page: 1 })}
/>
Overdue
</label>
<Button onClick={() => setParams({ q, page: 1 })}>นหา</Button>
</div>
<Card className="rounded-2xl border-0">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-white sticky top-0 border-b">
<tr className="text-left">
<th className="py-2 px-3">รห</th>
<th className="py-2 px-3">อเรอง</th>
<th className="py-2 px-3">สถานะ</th>
<th className="py-2 px-3">กำหนดส</th>
<th className="py-2 px-3">บผดชอบ</th>
</tr>
</thead>
<tbody>
{loading && <tr><td className="py-6 px-3" colSpan={5}>กำลงโหลด</td></tr>}
{error && !loading && <tr><td className="py-6 px-3 text-red-600" colSpan={5}>{error}</td></tr>}
{!loading && !error && rows.length === 0 && <tr><td className="py-6 px-3 opacity-70" colSpan={5}>ไมพบขอม</td></tr>}
{!loading && !error && rows.map((r) => (
<tr key={r.id} className="border-b hover:bg-gray-50">
<td className="py-2 px-3 font-mono">{r.code || r.id}</td>
<td className="py-2 px-3">{r.title}</td>
<td className="py-2 px-3">{r.status}</td>
<td className="py-2 px-3">{r.due_date || "—"}</td>
<td className="py-2 px-3">{r.owner_name || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between px-3 py-2 text-sm border-t">
<span>งหมด {total} รายการ</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => setParams({ page: Math.max(1, page - 1) })}
disabled={page <= 1}
>อนกล</Button>
<span>หน {page}/{pages}</span>
<Button
variant="outline"
onClick={() => setParams({ page: Math.min(pages, page + 1) })}
disabled={page >= pages}
>ดไป</Button>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

214
frontend/app/(protected)/transmittals/new/page.jsx Executable file → Normal file
View File

@@ -1,108 +1,108 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
export default function TransmittalNew() {
const router = useRouter();
const [draftId, setDraftId] = React.useState(null);
const [saving, setSaving] = React.useState(false);
const [savedAt, setSavedAt] = React.useState(null);
const [error, setError] = React.useState("");
const [form, setForm] = React.useState({
subject: "", number: "", to_party: "", sent_date: "", description: ""
});
const [errs, setErrs] = React.useState({});
const validate = (f) => {
const e = {};
if (!f.subject?.trim()) e.subject = "กรุณากรอกเรื่อง (Subject)";
if (!f.to_party?.trim()) e.to_party = "กรุณาระบุผู้รับ (To)";
if (!f.sent_date) e.sent_date = "กรุณาระบุวันที่ส่ง";
return e;
};
const tRef = React.useRef(0);
React.useEffect(() => {
clearTimeout(tRef.current);
tRef.current = window.setTimeout(async () => {
const e = validate(form);
setErrs(e);
try {
setSaving(true);
if (!draftId) {
const res = await api("/transmittals", { method: "POST", body: { ...form, status: "draft" } });
setDraftId(res.id);
} else {
await api(`/transmittals/${draftId}`, { method: "PATCH", body: { ...form, status: "draft" } });
}
setSavedAt(new Date());
} catch (err) {
setError(err.message || "บันทึกฉบับร่างไม่สำเร็จ");
} finally {
setSaving(false);
}
}, 800);
return () => clearTimeout(tRef.current);
}, [form, draftId]);
const onSubmit = async (e) => {
e.preventDefault();
const eobj = validate(form);
setErrs(eobj);
if (Object.keys(eobj).length) return;
try {
setSaving(true);
const id = draftId
? (await api(`/transmittals/${draftId}`, { method: "PATCH", body: { ...form, status: "submitted" } })).id || draftId
: (await api("/transmittals", { method: "POST", body: { ...form, status: "submitted" } })).id;
router.replace(`/transmittals`);
} catch (err) {
setError(err.message || "ส่ง Transmittal ไม่สำเร็จ");
} finally {
setSaving(false);
}
};
return (
<form onSubmit={onSubmit} className="space-y-4 rounded-2xl p-5 bg-white">
<div className="text-lg font-semibold">สราง Transmittal</div>
{error && <div className="text-sm text-red-600">{error}</div>}
<div className="grid md:grid-cols-2 gap-3">
<div>
<label className="text-sm">เรอง (Subject) *</label>
<Input value={form.subject} onChange={(e)=>setForm(f=>({...f, subject:e.target.value}))}/>
{errs.subject && <div className="text-xs text-red-600 mt-1">{errs.subject}</div>}
</div>
<div>
<label className="text-sm">เลขท (าม)</label>
<Input value={form.number} onChange={(e)=>setForm(f=>({...f, number:e.target.value}))}/>
</div>
<div>
<label className="text-sm"> (To) *</label>
<Input value={form.to_party} onChange={(e)=>setForm(f=>({...f, to_party:e.target.value}))}/>
{errs.to_party && <div className="text-xs text-red-600 mt-1">{errs.to_party}</div>}
</div>
<div>
<label className="text-sm">นท *</label>
<input type="date" className="border rounded-xl p-2 w-full" value={form.sent_date}
onChange={(e)=>setForm(f=>({...f, sent_date:e.target.value}))}/>
{errs.sent_date && <div className="text-xs text-red-600 mt-1">{errs.sent_date}</div>}
</div>
</div>
<div>
<label className="text-sm">รายละเอยด</label>
<textarea rows={5} className="border rounded-xl p-2 w-full"
value={form.description} onChange={(e)=>setForm(f=>({...f, description:e.target.value}))}/>
</div>
<div className="flex items-center gap-3">
<Button type="submit" disabled={saving}> Transmittal</Button>
<span className="text-sm opacity-70">
{saving ? "กำลังบันทึก…" : savedAt ? `บันทึกล่าสุด ${savedAt.toLocaleTimeString()}` : "ยังไม่เคยบันทึก"}
</span>
</div>
</form>
);
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
export default function TransmittalNew() {
const router = useRouter();
const [draftId, setDraftId] = React.useState(null);
const [saving, setSaving] = React.useState(false);
const [savedAt, setSavedAt] = React.useState(null);
const [error, setError] = React.useState("");
const [form, setForm] = React.useState({
subject: "", number: "", to_party: "", sent_date: "", description: ""
});
const [errs, setErrs] = React.useState({});
const validate = (f) => {
const e = {};
if (!f.subject?.trim()) e.subject = "กรุณากรอกเรื่อง (Subject)";
if (!f.to_party?.trim()) e.to_party = "กรุณาระบุผู้รับ (To)";
if (!f.sent_date) e.sent_date = "กรุณาระบุวันที่ส่ง";
return e;
};
const tRef = React.useRef(0);
React.useEffect(() => {
clearTimeout(tRef.current);
tRef.current = window.setTimeout(async () => {
const e = validate(form);
setErrs(e);
try {
setSaving(true);
if (!draftId) {
const res = await api("/transmittals", { method: "POST", body: { ...form, status: "draft" } });
setDraftId(res.id);
} else {
await api(`/transmittals/${draftId}`, { method: "PATCH", body: { ...form, status: "draft" } });
}
setSavedAt(new Date());
} catch (err) {
setError(err.message || "บันทึกฉบับร่างไม่สำเร็จ");
} finally {
setSaving(false);
}
}, 800);
return () => clearTimeout(tRef.current);
}, [form, draftId]);
const onSubmit = async (e) => {
e.preventDefault();
const eobj = validate(form);
setErrs(eobj);
if (Object.keys(eobj).length) return;
try {
setSaving(true);
const id = draftId
? (await api(`/transmittals/${draftId}`, { method: "PATCH", body: { ...form, status: "submitted" } })).id || draftId
: (await api("/transmittals", { method: "POST", body: { ...form, status: "submitted" } })).id;
router.replace(`/transmittals`);
} catch (err) {
setError(err.message || "ส่ง Transmittal ไม่สำเร็จ");
} finally {
setSaving(false);
}
};
return (
<form onSubmit={onSubmit} className="space-y-4 rounded-2xl p-5 bg-white">
<div className="text-lg font-semibold">สราง Transmittal</div>
{error && <div className="text-sm text-red-600">{error}</div>}
<div className="grid md:grid-cols-2 gap-3">
<div>
<label className="text-sm">เรอง (Subject) *</label>
<Input value={form.subject} onChange={(e)=>setForm(f=>({...f, subject:e.target.value}))}/>
{errs.subject && <div className="text-xs text-red-600 mt-1">{errs.subject}</div>}
</div>
<div>
<label className="text-sm">เลขท (าม)</label>
<Input value={form.number} onChange={(e)=>setForm(f=>({...f, number:e.target.value}))}/>
</div>
<div>
<label className="text-sm"> (To) *</label>
<Input value={form.to_party} onChange={(e)=>setForm(f=>({...f, to_party:e.target.value}))}/>
{errs.to_party && <div className="text-xs text-red-600 mt-1">{errs.to_party}</div>}
</div>
<div>
<label className="text-sm">นท *</label>
<input type="date" className="border rounded-xl p-2 w-full" value={form.sent_date}
onChange={(e)=>setForm(f=>({...f, sent_date:e.target.value}))}/>
{errs.sent_date && <div className="text-xs text-red-600 mt-1">{errs.sent_date}</div>}
</div>
</div>
<div>
<label className="text-sm">รายละเอยด</label>
<textarea rows={5} className="border rounded-xl p-2 w-full"
value={form.description} onChange={(e)=>setForm(f=>({...f, description:e.target.value}))}/>
</div>
<div className="flex items-center gap-3">
<Button type="submit" disabled={saving}> Transmittal</Button>
<span className="text-sm opacity-70">
{saving ? "กำลังบันทึก…" : savedAt ? `บันทึกล่าสุด ${savedAt.toLocaleTimeString()}` : "ยังไม่เคยบันทึก"}
</span>
</div>
</form>
);
}

190
frontend/app/(protected)/transmittals/page.jsx Executable file → Normal file
View File

@@ -1,96 +1,96 @@
"use client";
import React from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { apiGet } from "@/lib/api";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
export default function TransmittalsPage() {
const router = useRouter();
const pathname = usePathname();
const sp = useSearchParams();
const [q, setQ] = React.useState(sp.get("q") || "");
const page = Number(sp.get("page") || 1);
const pageSize = Number(sp.get("pageSize") || 20);
const sort = sp.get("sort") || "sent_date:desc";
const setParams = (patch) => {
const curr = Object.fromEntries(sp.entries());
const next = { ...curr, ...patch };
if (!next.q) delete next.q;
if (!next.page || Number(next.page) === 1) delete next.page;
if (!next.pageSize || Number(next.pageSize) === 20) delete next.pageSize;
if (!next.sort || next.sort === "sent_date:desc") delete next.sort;
const usp = new URLSearchParams(next).toString();
router.replace(`${pathname}${usp ? `?${usp}` : ""}`);
};
const [rows, setRows] = React.useState([]);
const [total, setTotal] = React.useState(0);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState("");
React.useEffect(() => {
setLoading(true); setError("");
apiGet("/transmittals", { q, page, pageSize, sort })
.then((res) => { setRows(res.data || []); setTotal(res.total || 0); })
.catch((e) => setError(e.message || "โหลดข้อมูลไม่สำเร็จ"))
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sp]);
const pages = Math.max(1, Math.ceil(total / pageSize));
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Input
placeholder="ค้นหา Transmittal (เลขที่/เรื่อง/ถึงใคร)"
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && setParams({ q, page: 1 })}
/>
<Button onClick={() => setParams({ q, page: 1 })}>นหา</Button>
</div>
<Card className="border-0 rounded-2xl">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="sticky top-0 bg-white border-b">
<tr className="text-left">
<th className="px-3 py-2">เลขท</th>
<th className="px-3 py-2">เรอง</th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2">นท</th>
</tr>
</thead>
<tbody>
{loading && <tr><td className="px-3 py-6" colSpan={4}>กำลงโหลด</td></tr>}
{error && !loading && <tr><td className="px-3 py-6 text-red-600" colSpan={4}>{error}</td></tr>}
{!loading && !error && rows.length === 0 && <tr><td className="px-3 py-6 opacity-70" colSpan={4}>ไมพบขอม</td></tr>}
{!loading && !error && rows.map((r) => (
<tr key={r.id} className="border-b hover:bg-gray-50">
<td className="px-3 py-2 font-mono">{r.number || r.id}</td>
<td className="px-3 py-2">{r.subject}</td>
<td className="px-3 py-2">{r.to_party}</td>
<td className="px-3 py-2">{r.sent_date || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between px-3 py-2 text-sm border-t">
<span>งหมด {total} รายการ</span>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setParams({ page: Math.max(1, page - 1) })} disabled={page <= 1}>อนกล</Button>
<span>หน {page}/{pages}</span>
<Button variant="outline" onClick={() => setParams({ page: Math.min(pages, page + 1) })} disabled={page >= pages}>ดไป</Button>
</div>
</div>
</CardContent>
</Card>
</div>
);
"use client";
import React from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { apiGet } from "@/lib/api";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
export default function TransmittalsPage() {
const router = useRouter();
const pathname = usePathname();
const sp = useSearchParams();
const [q, setQ] = React.useState(sp.get("q") || "");
const page = Number(sp.get("page") || 1);
const pageSize = Number(sp.get("pageSize") || 20);
const sort = sp.get("sort") || "sent_date:desc";
const setParams = (patch) => {
const curr = Object.fromEntries(sp.entries());
const next = { ...curr, ...patch };
if (!next.q) delete next.q;
if (!next.page || Number(next.page) === 1) delete next.page;
if (!next.pageSize || Number(next.pageSize) === 20) delete next.pageSize;
if (!next.sort || next.sort === "sent_date:desc") delete next.sort;
const usp = new URLSearchParams(next).toString();
router.replace(`${pathname}${usp ? `?${usp}` : ""}`);
};
const [rows, setRows] = React.useState([]);
const [total, setTotal] = React.useState(0);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState("");
React.useEffect(() => {
setLoading(true); setError("");
apiGet("/transmittals", { q, page, pageSize, sort })
.then((res) => { setRows(res.data || []); setTotal(res.total || 0); })
.catch((e) => setError(e.message || "โหลดข้อมูลไม่สำเร็จ"))
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sp]);
const pages = Math.max(1, Math.ceil(total / pageSize));
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Input
placeholder="ค้นหา Transmittal (เลขที่/เรื่อง/ถึงใคร)"
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && setParams({ q, page: 1 })}
/>
<Button onClick={() => setParams({ q, page: 1 })}>นหา</Button>
</div>
<Card className="border-0 rounded-2xl">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="sticky top-0 bg-white border-b">
<tr className="text-left">
<th className="px-3 py-2">เลขท</th>
<th className="px-3 py-2">เรอง</th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2">นท</th>
</tr>
</thead>
<tbody>
{loading && <tr><td className="px-3 py-6" colSpan={4}>กำลงโหลด</td></tr>}
{error && !loading && <tr><td className="px-3 py-6 text-red-600" colSpan={4}>{error}</td></tr>}
{!loading && !error && rows.length === 0 && <tr><td className="px-3 py-6 opacity-70" colSpan={4}>ไมพบขอม</td></tr>}
{!loading && !error && rows.map((r) => (
<tr key={r.id} className="border-b hover:bg-gray-50">
<td className="px-3 py-2 font-mono">{r.number || r.id}</td>
<td className="px-3 py-2">{r.subject}</td>
<td className="px-3 py-2">{r.to_party}</td>
<td className="px-3 py-2">{r.sent_date || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between px-3 py-2 text-sm border-t">
<span>งหมด {total} รายการ</span>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setParams({ page: Math.max(1, page - 1) })} disabled={page <= 1}>อนกล</Button>
<span>หน {page}/{pages}</span>
<Button variant="outline" onClick={() => setParams({ page: Math.min(pages, page + 1) })} disabled={page >= pages}>ดไป</Button>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

4
frontend/app/(protected)/users/page.jsx Executable file → Normal file
View File

@@ -1,3 +1,3 @@
export default function Page(){
return <div className="rounded-2xl p-5 bg-white">ใช/บทบาท ดการ RBAC</div>;
export default function Page(){
return <div className="rounded-2xl p-5 bg-white">ใช/บทบาท ดการ RBAC</div>;
}

4
frontend/app/(protected)/workflow/page.jsx Executable file → Normal file
View File

@@ -1,3 +1,3 @@
export default function Page(){
return <div className="rounded-2xl p-5 bg-white">Workflow (n8n) งก/ UI ตาม reverse proxy</div>;
export default function Page(){
return <div className="rounded-2xl p-5 bg-white">Workflow (n8n) งก/ UI ตาม reverse proxy</div>;
}

284
frontend/app/globals.css Executable file → Normal file
View File

@@ -1,146 +1,146 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ====== shadcn/ui theme (light + dark) ====== */
:root {
/* โทน “น้ำทะเล” ตามธีมของคุณ */
--primary: 199 90% 40%;
--primary-foreground: 0 0% 100%;
--secondary: 199 60% 92%;
--secondary-foreground: 220 15% 20%;
--muted: 210 20% 96%;
--muted-foreground: 220 10% 35%;
--accent: 199 95% 48%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--card: 0 0% 100%;
--card-foreground: 220 15% 15%;
--popover: 0 0% 100%;
--popover-foreground: 220 15% 15%;
--border: 214 32% 91%;
--input: 214 32% 91%;
--ring: 199 90% 40%;
--radius: 0.8rem; /* โค้งมนตามแนวทาง UI ของโปรเจ็ค */
}
.dark {
--background: 220 18% 10%;
--foreground: 0 0% 100%;
--primary: 199 95% 58%;
--primary-foreground: 220 18% 10%;
--secondary: 218 14% 20%;
--secondary-foreground: 0 0% 100%;
--muted: 220 14% 18%;
--muted-foreground: 220 10% 70%;
--accent: 199 95% 62%;
--accent-foreground: 220 18% 10%;
--destructive: 0 62% 46%;
--destructive-foreground: 0 0% 100%;
--card: 220 18% 12%;
--card-foreground: 0 0% 100%;
--popover: 220 18% 12%;
--popover-foreground: 0 0% 100%;
--border: 220 14% 28%;
--input: 220 14% 28%;
--ring: 199 95% 62%;
}
/* Base styling */
@layer base {
* {
@apply border-border;
}
html,
body {
@apply h-full;
}
body {
@apply bg-background text-foreground antialiased;
}
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
/* Utility: container max width (ช่วยเรื่อง layout) */
.container {
@apply mx-auto px-4;
}
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ====== shadcn/ui theme (light + dark) ====== */
:root {
/* โทน “น้ำทะเล” ตามธีมของคุณ */
--primary: 199 90% 40%;
--primary-foreground: 0 0% 100%;
--secondary: 199 60% 92%;
--secondary-foreground: 220 15% 20%;
--muted: 210 20% 96%;
--muted-foreground: 220 10% 35%;
--accent: 199 95% 48%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--card: 0 0% 100%;
--card-foreground: 220 15% 15%;
--popover: 0 0% 100%;
--popover-foreground: 220 15% 15%;
--border: 214 32% 91%;
--input: 214 32% 91%;
--ring: 199 90% 40%;
--radius: 0.8rem; /* โค้งมนตามแนวทาง UI ของโปรเจ็ค */
}
.dark {
--background: 220 18% 10%;
--foreground: 0 0% 100%;
--primary: 199 95% 58%;
--primary-foreground: 220 18% 10%;
--secondary: 218 14% 20%;
--secondary-foreground: 0 0% 100%;
--muted: 220 14% 18%;
--muted-foreground: 220 10% 70%;
--accent: 199 95% 62%;
--accent-foreground: 220 18% 10%;
--destructive: 0 62% 46%;
--destructive-foreground: 0 0% 100%;
--card: 220 18% 12%;
--card-foreground: 0 0% 100%;
--popover: 220 18% 12%;
--popover-foreground: 0 0% 100%;
--border: 220 14% 28%;
--input: 220 14% 28%;
--ring: 199 95% 62%;
}
/* Base styling */
@layer base {
* {
@apply border-border;
@apply border-border;
}
html,
body {
@apply h-full;
}
body {
@apply bg-background text-foreground;
}
}
@apply bg-background text-foreground antialiased;
}
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
/* Utility: container max width (ช่วยเรื่อง layout) */
.container {
@apply mx-auto px-4;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

312
frontend/app/layout.jsx Executable file → Normal file
View File

@@ -1,157 +1,157 @@
// frontend/app/layout.jsx
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
Bell,
Home,
Users,
Settings,
Package2,
FileText, // Added for example
LineChart, // Added for example
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
// **1. Import `useAuth` และ `can` จากไฟล์จริงของคุณ**
import { useAuth } from '@/lib/auth';
import { can } from '@/lib/rbac';
export default function ProtectedLayout({ children }) {
const pathname = usePathname();
// **2. เรียกใช้งาน useAuth hook เพื่อดึงข้อมูล user**
const { user, logout } = useAuth();
const navLinks = [
{ href: '/dashboard', label: 'Dashboard', icon: Home },
{ href: '/correspondences', label: 'Correspondences', icon: FileText },
{ href: '/drawings', label: 'Drawings', icon: FileText },
{ href: '/rfas', label: 'RFAs', icon: FileText },
{ href: '/transmittals', label: 'Transmittals', icon: FileText },
{ href: '/reports', label: 'Reports', icon: LineChart },
];
// **3. สร้าง object สำหรับเมนู Admin โดยเฉพาะ**
const adminLink = {
href: '/admin/users',
label: 'Admin',
icon: Settings,
requiredPermission: 'manage_users'
};
return (
<div className="grid min-h-screen w-full md:grid-cols-[220px_1fr] lg:grid-cols-[280px_1fr]">
<div className="hidden border-r bg-muted/40 md:block">
<div className="flex h-full max-h-screen flex-col gap-2">
<div className="flex h-14 items-center border-b px-4 lg:h-[60px] lg:px-6">
<Link href="/" className="flex items-center gap-2 font-semibold">
<Package2 className="h-6 w-6" />
<span className="">LCB P3 DMS</span>
</Link>
<Button variant="outline" size="icon" className="ml-auto h-8 w-8">
<Bell className="h-4 w-4" />
<span className="sr-only">Toggle notifications</span>
</Button>
</div>
<div className="flex-1">
<nav className="grid items-start px-2 text-sm font-medium lg:px-4">
{navLinks.map(({ href, label, icon: Icon }) => (
<Link
key={href}
href={href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground transition-all hover:text-primary',
pathname.startsWith(href) && 'bg-muted text-primary'
)}
>
<Icon className="h-4 w-4" />
{label}
</Link>
))}
{/* ====== ส่วนที่แก้ไข: ตรวจสอบสิทธิ์ด้วย `can` ====== */}
{user && can(user, adminLink.requiredPermission) && (
<>
<div className="my-2 border-t"></div>
<Link
href={adminLink.href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground transition-all hover:text-primary',
pathname.startsWith('/admin') && 'bg-muted text-primary'
)}
>
<adminLink.icon className="h-4 w-4" />
{adminLink.label}
</Link>
</>
)}
{/* ====== จบส่วนที่แก้ไข ====== */}
</nav>
</div>
<div className="mt-auto p-4">
<Card>
<CardHeader className="p-2 pt-0 md:p-4">
<CardTitle>Need Help?</CardTitle>
<CardDescription>
Contact support for any issues or questions.
</CardDescription>
</CardHeader>
<CardContent className="p-2 pt-0 md:p-4 md:pt-0">
<Button size="sm" className="w-full">
Contact
</Button>
</CardContent>
</Card>
</div>
</div>
</div>
<div className="flex flex-col">
<header className="flex h-14 items-center gap-4 border-b bg-muted/40 px-4 lg:h-[60px] lg:px-6">
{/* Mobile navigation can be added here */}
<div className="w-full flex-1">
{/* Optional: Add a search bar */}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" className="rounded-full">
<Users className="h-5 w-5" />
<span className="sr-only">Toggle user menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{user ? user.username : 'My Account'}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem>Support</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={logout}>Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</header>
<main className="flex flex-1 flex-col gap-4 p-4 lg:gap-6 lg:p-6">
{children}
</main>
</div>
</div>
);
// frontend/app/layout.jsx
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
Bell,
Home,
Users,
Settings,
Package2,
FileText, // Added for example
LineChart, // Added for example
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
// **1. Import `useAuth` และ `can` จากไฟล์จริงของคุณ**
import { useAuth } from '@/lib/auth';
import { can } from '@/lib/rbac';
export default function ProtectedLayout({ children }) {
const pathname = usePathname();
// **2. เรียกใช้งาน useAuth hook เพื่อดึงข้อมูล user**
const { user, logout } = useAuth();
const navLinks = [
{ href: '/dashboard', label: 'Dashboard', icon: Home },
{ href: '/correspondences', label: 'Correspondences', icon: FileText },
{ href: '/drawings', label: 'Drawings', icon: FileText },
{ href: '/rfas', label: 'RFAs', icon: FileText },
{ href: '/transmittals', label: 'Transmittals', icon: FileText },
{ href: '/reports', label: 'Reports', icon: LineChart },
];
// **3. สร้าง object สำหรับเมนู Admin โดยเฉพาะ**
const adminLink = {
href: '/admin/users',
label: 'Admin',
icon: Settings,
requiredPermission: 'manage_users'
};
return (
<div className="grid min-h-screen w-full md:grid-cols-[220px_1fr] lg:grid-cols-[280px_1fr]">
<div className="hidden border-r bg-muted/40 md:block">
<div className="flex h-full max-h-screen flex-col gap-2">
<div className="flex h-14 items-center border-b px-4 lg:h-[60px] lg:px-6">
<Link href="/" className="flex items-center gap-2 font-semibold">
<Package2 className="h-6 w-6" />
<span className="">LCB P3 DMS</span>
</Link>
<Button variant="outline" size="icon" className="ml-auto h-8 w-8">
<Bell className="h-4 w-4" />
<span className="sr-only">Toggle notifications</span>
</Button>
</div>
<div className="flex-1">
<nav className="grid items-start px-2 text-sm font-medium lg:px-4">
{navLinks.map(({ href, label, icon: Icon }) => (
<Link
key={href}
href={href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground transition-all hover:text-primary',
pathname.startsWith(href) && 'bg-muted text-primary'
)}
>
<Icon className="h-4 w-4" />
{label}
</Link>
))}
{/* ====== ส่วนที่แก้ไข: ตรวจสอบสิทธิ์ด้วย `can` ====== */}
{user && can(user, adminLink.requiredPermission) && (
<>
<div className="my-2 border-t"></div>
<Link
href={adminLink.href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground transition-all hover:text-primary',
pathname.startsWith('/admin') && 'bg-muted text-primary'
)}
>
<adminLink.icon className="h-4 w-4" />
{adminLink.label}
</Link>
</>
)}
{/* ====== จบส่วนที่แก้ไข ====== */}
</nav>
</div>
<div className="mt-auto p-4">
<Card>
<CardHeader className="p-2 pt-0 md:p-4">
<CardTitle>Need Help?</CardTitle>
<CardDescription>
Contact support for any issues or questions.
</CardDescription>
</CardHeader>
<CardContent className="p-2 pt-0 md:p-4 md:pt-0">
<Button size="sm" className="w-full">
Contact
</Button>
</CardContent>
</Card>
</div>
</div>
</div>
<div className="flex flex-col">
<header className="flex h-14 items-center gap-4 border-b bg-muted/40 px-4 lg:h-[60px] lg:px-6">
{/* Mobile navigation can be added here */}
<div className="w-full flex-1">
{/* Optional: Add a search bar */}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" className="rounded-full">
<Users className="h-5 w-5" />
<span className="sr-only">Toggle user menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{user ? user.username : 'My Account'}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem>Support</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={logout}>Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</header>
<main className="flex flex-1 flex-col gap-4 p-4 lg:gap-6 lg:p-6">
{children}
</main>
</div>
</div>
);
}

128
frontend/app/page.jsx Executable file → Normal file
View File

@@ -1,64 +1,64 @@
// app/page.jsx
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
export default function HomePage() {
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold">Welcome to DMS</h2>
<Tabs defaultValue="overview" className="w-full">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<div className="grid md:grid-cols-3 gap-4 mt-4">
<Card>
<CardHeader>
<CardTitle>📑 RFAs</CardTitle>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">24</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>📐 Drawings</CardTitle>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">112</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>📤 Transmittals</CardTitle>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">8</p>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="activity">
<div className="mt-4 space-y-3">
<p>
User <b>editor01</b> uploaded Drawing D-2025-07
</p>
<p> Transmittal T-2025-02 issued to Contractor</p>
<p> RFA R-2025-03 marked as Resolved</p>
</div>
</TabsContent>
</Tabs>
<Button className="mt-6">Go to Dashboard</Button>
</div>
);
}
// app/page.jsx
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
export default function HomePage() {
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold">Welcome to DMS</h2>
<Tabs defaultValue="overview" className="w-full">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<div className="grid md:grid-cols-3 gap-4 mt-4">
<Card>
<CardHeader>
<CardTitle>📑 RFAs</CardTitle>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">24</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>📐 Drawings</CardTitle>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">112</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>📤 Transmittals</CardTitle>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">8</p>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="activity">
<div className="mt-4 space-y-3">
<p>
User <b>editor01</b> uploaded Drawing D-2025-07
</p>
<p> Transmittal T-2025-02 issued to Contractor</p>
<p> RFA R-2025-03 marked as Resolved</p>
</div>
</TabsContent>
</Tabs>
<Button className="mt-6">Go to Dashboard</Button>
</div>
);
}

0
frontend/components/ui/badge.jsx Executable file → Normal file
View File

0
frontend/components/ui/button.jsx Executable file → Normal file
View File

0
frontend/components/ui/card.jsx Executable file → Normal file
View File

0
frontend/components/ui/dropdown-menu.jsx Executable file → Normal file
View File

0
frontend/components/ui/input.jsx Executable file → Normal file
View File

0
frontend/components/ui/progress.jsx Executable file → Normal file
View File

0
frontend/components/ui/switch.jsx Executable file → Normal file
View File

0
frontend/components/ui/tabs.jsx Executable file → Normal file
View File

0
frontend/components/ui/tooltip.jsx Executable file → Normal file
View File

18
frontend/jsconfig.json Executable file → Normal file
View File

@@ -1,9 +1,9 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"exclude": ["node_modules", ".next"]
}
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"exclude": ["node_modules", ".next"]
}

78
frontend/lib/api.js Executable file → Normal file
View File

@@ -1,39 +1,39 @@
// ใช้ NEXT_PUBLIC_API_BASE จาก docker-compose (ไม่ใช้ .env file)
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE || "http://localhost:3001";
// NEW: querystring helper (รองรับ array)
export function qstr(params = {}) {
const sp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v === undefined || v === null || v === "") return;
if (Array.isArray(v)) v.forEach((x) => sp.append(k, String(x)));
else sp.set(k, String(v));
});
const s = sp.toString();
return s ? `?${s}` : "";
}
// NEW: GET helper
export function apiGet(path, params = {}, opts = {}) {
return api(`${path}${qstr(params)}`, { ...opts, method: "GET" });
}
export async function api(path, { method = "GET", body, headers = {}, cache = "no-store" } = {}) {
const res = await fetch(`${API_BASE}${path}`, {
method,
headers: {
"Content-Type": "application/json",
...headers,
},
body: body ? JSON.stringify(body) : undefined,
credentials: "include",
cache,
next: { revalidate: 0 },
});
if (!res.ok) {
const text = await res.text().catch(()=>"");
throw new Error(`API ${method} ${path} ${res.status} ${text}`);
}
const ct = res.headers.get("content-type") || "";
return ct.includes("application/json") ? res.json() : res.text();
}
// ใช้ NEXT_PUBLIC_API_BASE จาก docker-compose (ไม่ใช้ .env file)
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE || "http://localhost:3001";
// NEW: querystring helper (รองรับ array)
export function qstr(params = {}) {
const sp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v === undefined || v === null || v === "") return;
if (Array.isArray(v)) v.forEach((x) => sp.append(k, String(x)));
else sp.set(k, String(v));
});
const s = sp.toString();
return s ? `?${s}` : "";
}
// NEW: GET helper
export function apiGet(path, params = {}, opts = {}) {
return api(`${path}${qstr(params)}`, { ...opts, method: "GET" });
}
export async function api(path, { method = "GET", body, headers = {}, cache = "no-store" } = {}) {
const res = await fetch(`${API_BASE}${path}`, {
method,
headers: {
"Content-Type": "application/json",
...headers,
},
body: body ? JSON.stringify(body) : undefined,
credentials: "include",
cache,
next: { revalidate: 0 },
});
if (!res.ok) {
const text = await res.text().catch(()=>"");
throw new Error(`API ${method} ${path} ${res.status} ${text}`);
}
const ct = res.headers.get("content-type") || "";
return ct.includes("application/json") ? res.json() : res.text();
}

76
frontend/lib/auth.js Executable file → Normal file
View File

@@ -1,38 +1,38 @@
// frontend/lib/auth.js
import { cookies } from "next/headers";
const COOKIE_NAME = "access_token";
/**
* Server-side session fetcher (ใช้ใน Server Components/Layouts)
* - อ่านคุกกี้แบบ async: await cookies()
* - ถ้าไม่มี token → return null
* - ถ้ามี → เรียก /api/auth/me ที่ backend เพื่อตรวจสอบ
*/
export async function getSession() {
// ✅ ต้อง await
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (!token) return null;
// เรียก backend ตรวจ session (ปรับ endpoint ให้ตรงของคุณ)
const res = await fetch(`${process.env.NEXT_PUBLIC_API_BASE}/api/auth/me`, {
// ส่งต่อคุกกี้ไป backend (เลือกอย่างใดอย่างหนึ่ง)
// วิธี A: ส่ง header Cookie โดยตรง
headers: { Cookie: `${COOKIE_NAME}=${token}` },
// วิธี B: ถ้า proxy ผ่าน nginx ในโดเมนเดียวกัน ใช้ credentials รวมคุกกี้อัตโนมัติได้
// credentials: "include",
cache: "no-store",
});
if (!res.ok) return null;
const data = await res.json();
// คาดหวังโครงสร้าง { user, permissions } จาก backend
return {
user: data.user,
permissions: data.permissions || [],
token,
};
}
// frontend/lib/auth.js
import { cookies } from "next/headers";
const COOKIE_NAME = "access_token";
/**
* Server-side session fetcher (ใช้ใน Server Components/Layouts)
* - อ่านคุกกี้แบบ async: await cookies()
* - ถ้าไม่มี token → return null
* - ถ้ามี → เรียก /api/auth/me ที่ backend เพื่อตรวจสอบ
*/
export async function getSession() {
// ✅ ต้อง await
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (!token) return null;
// เรียก backend ตรวจ session (ปรับ endpoint ให้ตรงของคุณ)
const res = await fetch(`${process.env.NEXT_PUBLIC_API_BASE}/api/auth/me`, {
// ส่งต่อคุกกี้ไป backend (เลือกอย่างใดอย่างหนึ่ง)
// วิธี A: ส่ง header Cookie โดยตรง
headers: { Cookie: `${COOKIE_NAME}=${token}` },
// วิธี B: ถ้า proxy ผ่าน nginx ในโดเมนเดียวกัน ใช้ credentials รวมคุกกี้อัตโนมัติได้
// credentials: "include",
cache: "no-store",
});
if (!res.ok) return null;
const data = await res.json();
// คาดหวังโครงสร้าง { user, permissions } จาก backend
return {
user: data.user,
permissions: data.permissions || [],
token,
};
}

16
frontend/lib/rbac.js Executable file → Normal file
View File

@@ -1,8 +1,8 @@
// lib/rbac.js
export function can(user, perm) {
const set = new Set(user?.permissions || []);
return set.has(perm);
}
export function inRole(user, ...roles) {
return roles.includes(user?.role);
}
// lib/rbac.js
export function can(user, perm) {
const set = new Set(user?.permissions || []);
return set.has(perm);
}
export function inRole(user, ...roles) {
return roles.includes(user?.role);
}

12
frontend/lib/utils.js Executable file → Normal file
View File

@@ -1,6 +1,6 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs));
}

122
frontend/middleware.ts Executable file → Normal file
View File

@@ -1,61 +1,61 @@
// frontend/middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
// ให้ตรงกับชื่อคุกกี้ที่ backend เซ็ต
const COOKIE_NAME = "access_token";
// หน้าที่เปิดได้โดยไม่ต้องล็อกอิน (ถ้าต้องการเพิ่มให้ใส่เพิ่มที่นี่)
const PUBLIC_PREFIXES = ["/login", "/register", "/health", "/api/health"];
// const PUBLIC_PATHS = new Set<string>(["/login", "/register", "/health"]);
export function middleware(req: NextRequest) {
const { pathname, search } = req.nextUrl;
// อนุญาตไฟล์สาธารณะและ Static assets
if (
pathname.startsWith("/_next/") ||
pathname.startsWith("/public/") ||
pathname === "/favicon.ico"
) {
return NextResponse.next();
}
// หน้าสาธารณะ: ปล่อยผ่าน
// if (PUBLIC_PATHS.has(pathname)) {
// return NextResponse.next();
// }
// อนุญาตหน้า public
if (PUBLIC_PREFIXES.some((p) => pathname.startsWith(p))) {
return NextResponse.next();
}
// ตรวจ token จาก cookie
const token = req.cookies.get(COOKIE_NAME)?.value;
// ไม่มี token → เด้งไป /login พร้อม next=path เดิม
if (!token) {
const url = req.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("next", pathname + (search || ""));
return NextResponse.redirect(url);
}
// มี token → ผ่าน
return NextResponse.next();
}
// จำกัดให้ตรวจเฉพาะเส้นทาง protected (ลดโอเวอร์เฮด)
export const config = {
matcher: [
"/dashboard/:path*",
"/drawings/:path*",
"/rfas/:path*",
"/transmittals/:path*",
"/correspondences/:path*",
"/contracts-volumes/:path*",
"/users/:path*",
"/reports/:path*",
"/workflow/:path*",
"/admin/:path*",
],
};
// frontend/middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
// ให้ตรงกับชื่อคุกกี้ที่ backend เซ็ต
const COOKIE_NAME = "access_token";
// หน้าที่เปิดได้โดยไม่ต้องล็อกอิน (ถ้าต้องการเพิ่มให้ใส่เพิ่มที่นี่)
const PUBLIC_PREFIXES = ["/login", "/register", "/health", "/api/health"];
// const PUBLIC_PATHS = new Set<string>(["/login", "/register", "/health"]);
export function middleware(req: NextRequest) {
const { pathname, search } = req.nextUrl;
// อนุญาตไฟล์สาธารณะและ Static assets
if (
pathname.startsWith("/_next/") ||
pathname.startsWith("/public/") ||
pathname === "/favicon.ico"
) {
return NextResponse.next();
}
// หน้าสาธารณะ: ปล่อยผ่าน
// if (PUBLIC_PATHS.has(pathname)) {
// return NextResponse.next();
// }
// อนุญาตหน้า public
if (PUBLIC_PREFIXES.some((p) => pathname.startsWith(p))) {
return NextResponse.next();
}
// ตรวจ token จาก cookie
const token = req.cookies.get(COOKIE_NAME)?.value;
// ไม่มี token → เด้งไป /login พร้อม next=path เดิม
if (!token) {
const url = req.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("next", pathname + (search || ""));
return NextResponse.redirect(url);
}
// มี token → ผ่าน
return NextResponse.next();
}
// จำกัดให้ตรวจเฉพาะเส้นทาง protected (ลดโอเวอร์เฮด)
export const config = {
matcher: [
"/dashboard/:path*",
"/drawings/:path*",
"/rfas/:path*",
"/transmittals/:path*",
"/correspondences/:path*",
"/contracts-volumes/:path*",
"/users/:path*",
"/reports/:path*",
"/workflow/:path*",
"/admin/:path*",
],
};

52
frontend/next.config.js Executable file → Normal file
View File

@@ -1,26 +1,26 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
// แนะนำย้ายค่าพวกนี้ไป .env(.local) แล้วใช้ process.env ฝั่งโค้ดแทน
// เก็บไว้ชั่วคราวได้ถ้ายังไม่ทำ .env
env: {
NEXT_PUBLIC_API_BASE: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:8080"
},
images: {
remotePatterns: [
// { protocol: "https", hostname: "lcbp3.np-dms.work" },
],
},
// ลดโอกาส build ล้มเพราะ lint (เฉพาะ production build)
eslint: {
ignoreDuringBuilds: true,
},
// ถ้าไม่มี TypeScript ไม่ต้องใส่ส่วนนี้
// typescript: { ignoreBuildErrors: true },
};
module.exports = nextConfig;
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
// แนะนำย้ายค่าพวกนี้ไป .env(.local) แล้วใช้ process.env ฝั่งโค้ดแทน
// เก็บไว้ชั่วคราวได้ถ้ายังไม่ทำ .env
env: {
NEXT_PUBLIC_API_BASE: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:8080"
},
images: {
remotePatterns: [
// { protocol: "https", hostname: "lcbp3.np-dms.work" },
],
},
// ลดโอกาส build ล้มเพราะ lint (เฉพาะ production build)
eslint: {
ignoreDuringBuilds: true,
},
// ถ้าไม่มี TypeScript ไม่ต้องใส่ส่วนนี้
// typescript: { ignoreBuildErrors: true },
};
module.exports = nextConfig;

14998
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

84
frontend/package.json Executable file → Normal file
View File

@@ -1,42 +1,42 @@
{
"name": "dms-frontend",
"version": "0.7.0",
"private": true,
"scripts": {
"dev": "next dev -H 0.0.0.0 -p 3000",
"build": "next build",
"start": "next start -H 0.0.0.0 -p 3000",
"lint": "next lint",
"format": "prettier --write ."
},
"dependencies": {
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"autoprefixer": "10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^11.2.10",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.451.0",
"next": "15.0.3",
"postcss": "8.4.47",
"react": "18.3.1",
"react-dom": "18.3.1",
"tailwind-merge": "^2.6.0",
"tailwindcss": "3.4.14",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"eslint": "9.13.0",
"eslint-config-next": "15.0.3",
"prettier": "3.3.3"
},
"engines": {
"node": ">=20.0.0"
}
}
{
"name": "dms-frontend",
"version": "0.7.0",
"private": true,
"scripts": {
"dev": "next dev -H 0.0.0.0 -p 3000",
"build": "next build",
"start": "next start -H 0.0.0.0 -p 3000",
"lint": "next lint",
"format": "prettier --write ."
},
"dependencies": {
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"autoprefixer": "10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^11.2.10",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.451.0",
"next": "15.0.3",
"postcss": "8.4.47",
"react": "18.3.1",
"react-dom": "18.3.1",
"tailwind-merge": "^2.6.0",
"tailwindcss": "3.4.14",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"eslint": "9.13.0",
"eslint-config-next": "15.0.3",
"prettier": "3.3.3"
},
"engines": {
"node": ">=20.0.0"
}
}

798
frontend/page.jsx Executable file → Normal file
View File

@@ -1,400 +1,400 @@
"use client";
import React from "react";
import Link from "next/link";
import { motion } from "framer-motion";
import {
LayoutDashboard, FileText, Files, Send, Layers, Users, Settings, Activity,
Search, ChevronRight, ShieldCheck, Workflow, Database, Mail, Server, Shield, BookOpen,
PanelLeft, PanelRight, ChevronDown, Plus, Filter, Eye, EyeOff, SlidersHorizontal, Columns3, X, ExternalLink
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Progress } from "@/components/ui/progress";
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Switch } from "@/components/ui/switch";
import { API_BASE } from "@/lib/api";
// Sea palette
const sea = { light: "#E6F7FB", light2: "#F3FBFD", mid: "#2A7F98", dark: "#0D5C75", textDark: "#0E2932" };
// RBAC helper (client-side; backend truth comes from /auth/me)
function can(user, perm){
const set = new Set(user?.permissions || []);
return set.has(perm);
}
function Tag({ children }) {
return (
<Badge className="rounded-full px-3 py-1 text-xs border-0" style={{ background: sea.light, color: sea.dark }}>{children}</Badge>
);
}
function SidebarItem({ label, icon: Icon, active=false, badge }) {
return (
<button className={`group w-full flex items-center gap-3 rounded-2xl px-4 py-3 text-left transition-all border ${active?"bg-white/70":"bg-white/30 hover:bg-white/60"}`} style={{ borderColor: "#ffffff40", color: sea.textDark }}>
<Icon className="h-5 w-5" />
<span className="grow font-medium">{label}</span>
{badge ? <span className="text-xs rounded-full px-2 py-0.5" style={{ background: sea.light, color: sea.dark }}>{badge}</span> : null}
<ChevronRight className="h-4 w-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
);
}
function KPI({ label, value, icon: Icon, onClick }) {
return (
<Card onClick={onClick} className="rounded-2xl shadow-sm border-0 cursor-pointer hover:shadow transition" style={{ background: "white" }}>
<CardContent className="p-5">
<div className="flex items-start justify-between">
<span className="text-sm opacity-70">{label}</span>
<div className="rounded-xl p-2" style={{ background: sea.light }}>
<Icon className="h-5 w-5" style={{ color: sea.dark }} />
</div>
</div>
<div className="mt-3 text-3xl font-bold" style={{ color: sea.textDark }}>{value}</div>
<div className="mt-2"><Progress value={Math.min(100, (value/400)*100)} /></div>
</CardContent>
</Card>
);
}
function PreviewDrawer({ open, onClose, children }) {
return (
<div className={`fixed top-0 right-0 h-full w-full sm:w-[420px] bg-white shadow-2xl transition-transform z-50 ${open ? "translate-x-0" : "translate-x-full"}`}>
<div className="flex items-center justify-between p-4 border-b">
<div className="font-medium">รายละเอยด</div>
<Button variant="ghost" size="icon" onClick={onClose}><X className="h-5 w-5"/></Button>
</div>
<div className="p-4 overflow-auto h-[calc(100%-56px)]">{children}</div>
</div>
);
}
export default function DashboardPage(){
// ดึง user จริงจาก /auth/me (layout ฝั่ง SSR ตรวจสิทธิ์แล้ว)
const [user, setUser] = React.useState(null);
const [sidebarOpen, setSidebarOpen] = React.useState(true);
const [densityCompact, setDensityCompact] = React.useState(false);
const [showCols, setShowCols] = React.useState({ type:true, id:true, title:true, status:true, due:true, owner:true, actions:true });
const [previewOpen, setPreviewOpen] = React.useState(false);
const [filters, setFilters] = React.useState({ type:"All", status:"All", overdue:false });
const [activeQuery, setActiveQuery] = React.useState({});
React.useEffect(()=>{
fetch(`${API_BASE}/auth/me`, { credentials: "include" })
.then(r=> r.ok ? r.json() : null)
.then(data => setUser(data?.user || null))
.catch(()=> setUser(null));
},[]);
const quickLinks = [
{ label: "สร้าง RFA", icon: FileText, perm: "rfa:create", href: "/rfas/new" },
{ label: "อัปโหลด Drawing", icon: Layers, perm: "drawing:upload", href: "/drawings/upload" },
{ label: "สร้าง Transmittal", icon: Send, perm: "transmittal:create", href: "/transmittals/new" },
{ label: "บันทึกหนังสือสื่อสาร", icon: Mail, perm: "correspondence:create", href: "/correspondences/new" },
];
const nav = [
{ label: "แดชบอร์ด", icon: LayoutDashboard },
{ label: "Drawings", icon: Layers },
{ label: "RFAs", icon: FileText },
{ label: "Transmittals", icon: Send },
{ label: "Contracts & Volumes", icon: BookOpen },
{ label: "Correspondences", icon: Files },
{ label: "ผู้ใช้/บทบาท", icon: Users, perm: "users:manage" },
{ label: "Reports", icon: Activity },
{ label: "Workflow (n8n)", icon: Workflow, perm: "workflow:view" },
{ label: "Health", icon: Server, perm: "health:view" },
{ label: "Admin", icon: Settings, perm: "admin:view" },
];
const kpis = [
{ key: "rfa-pending", label: "RFAs รออนุมัติ", value: 12, icon: FileText, query: { type: "RFA", status: "pending" } },
{ key: "drawings", label: "แบบ (Drawings) ล่าสุด", value: 326, icon: Layers, query: { type: "Drawing" } },
{ key: "trans-month", label: "Transmittals เดือนนี้", value: 18, icon: Send, query: { type: "Transmittal", month: "current" } },
{ key: "overdue", label: "เกินกำหนด (Overdue)", value: 5, icon: Activity, query: { overdue: true } },
];
const recent = [
{ type: "RFA", code: "RFA-LCP3-0012", title: "ปรับปรุงรายละเอียดเสาเข็มท่าเรือ", who: "สุรเชษฐ์ (Editor)", when: "เมื่อวาน 16:40" },
{ type: "Drawing", code: "DWG-C-210A-Rev.3", title: "แปลนโครงสร้างท่าเรือส่วนที่ 2", who: "วรวิชญ์ (Admin)", when: "วันนี้ 09:15" },
{ type: "Transmittal", code: "TR-2025-0916-04", title: "ส่งแบบ Rebar Shop Drawing ชุด A", who: "Supansa (Viewer)", when: "16 ก.ย. 2025" },
{ type: "Correspondence", code: "CRSP-58", title: "แจ้งเลื่อนประชุมตรวจแบบ", who: "Kitti (Editor)", when: "15 ก.ย. 2025" },
];
const items = [
{t:"RFA", id:"RFA-LCP3-0013", title:"ยืนยันรายละเอียดท่อระบายน้ำ", status:"Pending", due:"20 ก.ย. 2025", owner:"คุณแดง"},
{t:"Drawing", id:"DWG-S-115-Rev.1", title:"Section เสาเข็มพื้นที่ส่วนที่ 1", status:"Review", due:"19 ก.ย. 2025", owner:"วิทยา"},
{t:"Transmittal", id:"TR-2025-0915-03", title:"ส่งแบบโครงสร้างท่าเรือ ชุด B", status:"Sent", due:"—", owner:"สุธิดา"},
];
const visibleItems = items.filter(r => {
if (filters.type !== "All" && r.t !== filters.type) return false;
if (filters.status !== "All" && r.status !== filters.status) return false;
if (filters.overdue && r.due === "—") return false;
return true;
});
const onKpiClick = (q) => {
setActiveQuery(q);
if (q?.type) setFilters(f => ({...f, type:q.type}));
if (q?.overdue) setFilters(f => ({...f, overdue:true}));
};
return (
<TooltipProvider>
<div className="min-h-screen" style={{ background: `linear-gradient(180deg, ${sea.light2} 0%, ${sea.light} 100%)` }}>
{/* Top System + Quick Actions */}
<header className="sticky top-0 z-40 backdrop-blur-md border-b" style={{ borderColor: "#ffffff66", background: "rgba(230,247,251,0.7)" }}>
<div className="mx-auto max-w-7xl px-4 py-2 flex items-center gap-3">
<button className="h-9 w-9 rounded-2xl flex items-center justify-center shadow-sm" style={{ background: sea.dark }} onClick={()=>setSidebarOpen(v=>!v)} aria-label={sidebarOpen?"ซ่อนแถบด้านข้าง":"แสดงแถบด้านข้าง"}>
{sidebarOpen ? <PanelLeft className="h-5 w-5 text-white"/> : <PanelRight className="h-5 w-5 text-white"/>}
</button>
<div>
<div className="text-xs opacity-70">Document Management System</div>
<div className="font-semibold" style={{ color: sea.textDark }}>โครงการพฒนาทาเรอแหลมฉบ ระยะท 3 วนท 14</div>
</div>
<Tag>Phase 3</Tag><Tag>Port Infrastructure</Tag>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="ml-auto rounded-2xl btn-sea flex items-center gap-2">System <ChevronDown className="h-4 w-4"/></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-56">
<DropdownMenuLabel>ระบบ</DropdownMenuLabel>
{can(user,'admin:view') && <DropdownMenuItem><Settings className="h-4 w-4 mr-2"/> Admin</DropdownMenuItem>}
{can(user,'users:manage') && <DropdownMenuItem><Users className="h-4 w-4 mr-2"/> ใช/บทบาท</DropdownMenuItem>}
{can(user,'health:view') && <DropdownMenuItem asChild><a href="/health" className="flex items-center w-full"><Server className="h-4 w-4 mr-2"/> Health <ExternalLink className="h-3 w-3 ml-auto"/></a></DropdownMenuItem>}
{can(user,'workflow:view') && <DropdownMenuItem asChild><a href="/workflow" className="flex items-center w-full"><Workflow className="h-4 w-4 mr-2"/> Workflow (n8n) <ExternalLink className="h-3 w-3 ml-auto"/></a></DropdownMenuItem>}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="rounded-2xl btn-sea ml-2"><Plus className="h-4 w-4 mr-1"/> New</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{quickLinks.map(({label, icon:Icon, perm, href}) => (
can(user,perm) ? (
<DropdownMenuItem key={label} asChild>
<Link href={href} className="flex items-center"><Icon className="h-4 w-4 mr-2"/>{label}</Link>
</DropdownMenuItem>
) : (
<Tooltip key={label}>
<TooltipTrigger asChild>
<div className="px-2 py-1.5 text-sm opacity-40 cursor-not-allowed flex items-center"><Icon className="h-4 w-4 mr-2"/>{label}</div>
</TooltipTrigger>
<TooltipContent>ไมทธใชงาน ({perm})</TooltipContent>
</Tooltip>
)
))}
<DropdownMenuSeparator />
<DropdownMenuItem><Layers className="h-4 w-4 mr-2"/> Import / Bulk upload</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<div className="mx-auto max-w-7xl px-4 py-6 grid grid-cols-12 gap-6">
{/* Sidebar */}
{sidebarOpen && (
<aside className="col-span-12 lg:col-span-3 xl:col-span-3">
<div className="rounded-3xl p-4 border" style={{ background: "rgba(255,255,255,0.7)", borderColor: "#ffffff66" }}>
<div className="mb-3 flex items-center gap-2">
<ShieldCheck className="h-5 w-5" style={{ color: sea.dark }} />
<div className="text-sm">RBAC: <span className="font-medium">{user?.role || '—'}</span></div>
</div>
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 opacity-70" />
<Input placeholder="ค้นหา RFA / Drawing / Transmittal / Code…" className="pl-9 rounded-2xl border-0 bg-white"/>
</div>
<div className="rounded-2xl p-3 border mb-3" style={{ borderColor: "#eef6f8", background: "#ffffffaa" }}>
<div className="text-xs font-medium mb-2">วกรอง</div>
<div className="grid grid-cols-2 gap-2">
<select className="rounded-xl border p-2 text-sm" value={filters.type} onChange={e=>setFilters(f=>({...f,type:e.target.value}))}>
<option>All</option><option>RFA</option><option>Drawing</option><option>Transmittal</option><option>Correspondence</option>
</select>
<select className="rounded-xl border p-2 text-sm" value={filters.status} onChange={e=>setFilters(f=>({...f,status:e.target.value}))}>
<option>All</option><option>Pending</option><option>Review</option><option>Sent</option>
</select>
<label className="col-span-2 flex items-center gap-2 text-sm">
<Switch checked={filters.overdue} onCheckedChange={(v)=>setFilters(f=>({...f, overdue:v}))}/> แสดงเฉพาะ Overdue
</label>
</div>
<div className="mt-2 flex gap-2">
<Button size="sm" variant="outline" className="rounded-xl" style={{ borderColor: sea.mid, color: sea.dark }}><Filter className="h-4 w-4 mr-1"/>Apply</Button>
<Button size="sm" variant="ghost" className="rounded-xl" onClick={()=>setFilters({type:"All",status:"All",overdue:false})}>Reset</Button>
</div>
</div>
<div className="space-y-2">
{nav.filter(item => !item.perm || can(user,item.perm)).map((n,i)=> (
<SidebarItem key={n.label} label={n.label} icon={n.icon} active={i===0} badge={n.label === "RFAs" ? 12 : undefined} />
))}
</div>
<div className="mt-5 text-xs opacity-70 flex items-center gap-2">
<Database className="h-4 w-4"/> dms_db MariaDB 10.11
</div>
</div>
</aside>
)}
{/* Content */}
<main className={`col-span-12 ${sidebarOpen?"lg:col-span-9 xl:col-span-9":""} space-y-6`}>
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.05, duration: 0.4 }}>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
{kpis.map((k)=> <KPI key={k.key} {...k} onClick={()=>onKpiClick(k.query)} />)}
</div>
</motion.div>
<div className="flex items-center justify-between">
<div className="text-sm opacity-70">ผลลพธจากตวกรอง: {filters.type}/{filters.status}{filters.overdue?" • Overdue":""}</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="rounded-xl" style={{ borderColor: sea.mid, color: sea.dark }} onClick={()=>setDensityCompact(v=>!v)}>
<SlidersHorizontal className="h-4 w-4 mr-1"/> Density: {densityCompact?"Compact":"Comfort"}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" className="rounded-xl" style={{ borderColor: sea.mid, color: sea.dark }}>
<Columns3 className="h-4 w-4 mr-1"/> Columns
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{Object.keys(showCols).map(key => (
<DropdownMenuItem key={key} onClick={() => setShowCols(s=>({...s, [key]: !s[key]}))}>
{(showCols[key]?<Eye className="h-4 w-4 mr-2"/>:<EyeOff className="h-4 w-4 mr-2"/>)}{key}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<Card className="rounded-2xl border-0"><CardContent className="p-0">
<div className="overflow-x-auto">
<table className={`min-w-full text-sm ${densityCompact?"[&_*]:py-1":""}`}>
<thead className="sticky top-[56px] z-10" style={{ background:"white", borderBottom:"1px solid #efefef" }}>
<tr className="text-left">
{showCols.type && <th className="py-2 px-3">ประเภท</th>}
{showCols.id && <th className="py-2 px-3">รห</th>}
{showCols.title && <th className="py-2 px-3">อเรอง</th>}
{showCols.status && <th className="py-2 px-3">สถานะ</th>}
{showCols.due && <th className="py-2 px-3">กำหนดส</th>}
{showCols.owner && <th className="py-2 px-3">บผดชอบ</th>}
{showCols.actions && <th className="py-2 px-3">ดการ</th>}
</tr>
</thead>
<tbody>
{visibleItems.length === 0 && (<tr><td className="py-8 px-3 text-center opacity-70" colSpan={7}>ไมพบรายการตามตวกรองทเลอก</td></tr>)}
{visibleItems.map(row => (
<tr key={row.id} className="border-b hover:bg-gray-50/50 cursor-pointer" style={{ borderColor: "#f3f3f3" }} onClick={()=>setPreviewOpen(true)}>
{showCols.type && <td className="py-2 px-3">{row.t}</td>}
{showCols.id && <td className="py-2 px-3 font-mono">{row.id}</td>}
{showCols.title && <td className="py-2 px-3">{row.title}</td>}
{showCols.status && <td className="py-2 px-3"><Tag>{row.status}</Tag></td>}
{showCols.due && <td className="py-2 px-3">{row.due}</td>}
{showCols.owner && <td className="py-2 px-3">{row.owner}</td>}
{showCols.actions && <td className="py-2 px-3"><div className="flex gap-2"><Button size="sm" className="rounded-xl btn-sea">เป</Button><Button size="sm" variant="outline" className="rounded-xl" style={{ borderColor: sea.mid, color: sea.dark }}>Assign</Button></div></td>}
</tr>
))}
</tbody>
</table>
</div>
<div className="px-4 py-2 text-xs opacity-70 border-t" style={{ borderColor: "#efefef"}}>เคลดล: ใช / เลอนแถว, Enter เป, / </div>
</CardContent></Card>
<Tabs defaultValue="overview" className="w-full">
<TabsList className="rounded-2xl border bg-white/80" style={{ borderColor: "#ffffff80" }}>
<TabsTrigger value="overview">ภาพรวม</TabsTrigger>
<TabsTrigger value="reports">รายงาน</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4 space-y-4">
<div className="grid lg:grid-cols-5 gap-4">
<Card className="rounded-2xl border-0 lg:col-span-3"><CardContent className="p-5">
<div className="flex items-center justify-between"><div className="font-semibold" style={{ color: sea.textDark }}>สถานะโครงการ</div><Tag>Phase 3 วนท 14</Tag></div>
<div className="mt-4 space-y-3">
<div><div className="text-sm opacity-70">ความคบหนาโดยรวม</div><Progress value={62} /></div>
<div className="grid grid-cols-3 gap-3">
<div className="rounded-xl p-4 border" style={{ background: sea.light, borderColor: sea.light }}><div className="text-xs opacity-70">วนท 1</div><div className="text-lg font-semibold">เสร 70%</div></div>
<div className="rounded-xl p-4 border" style={{ background: sea.light, borderColor: sea.light }}><div className="text-xs opacity-70">วนท 2</div><div className="text-lg font-semibold">เสร 58%</div></div>
<div className="rounded-xl p-4 border" style={{ background: sea.light, borderColor: sea.light }}><div className="text-xs opacity-70">วนท 34</div><div className="text-lg font-semibold">เสร 59%</div></div>
</div>
</div>
</CardContent></Card>
<Card className="rounded-2xl border-0 lg:col-span-2"><CardContent className="p-5 space-y-3">
<div className="flex items-center justify-between"><div className="font-semibold" style={{ color: sea.textDark }}>System Health</div><Tag>QNAP Container Station</Tag></div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2"><Server className="h-4 w-4"/> Nginx Reverse Proxy <span className="ml-auto font-medium" style={{ color: sea.dark }}>Healthy</span></div>
<div className="flex items-center gap-2"><Database className="h-4 w-4"/> MariaDB 10.11 <span className="ml-auto font-medium" style={{ color: sea.dark }}>OK</span></div>
<div className="flex items-center gap-2"><Workflow className="h-4 w-4"/> n8n (Postgres) <span className="ml-auto font-medium" style={{ color: sea.dark }}>OK</span></div>
<div className="flex items-center gap-2"><Shield className="h-4 w-4"/> RBAC Enforcement <span className="ml-auto font-medium" style={{ color: sea.dark }}>Enabled</span></div>
</div>
<div className="pt-2 border-t" style={{ borderColor: "#eeeeee" }}>
<Button variant="outline" className="rounded-xl" style={{ borderColor: sea.mid, color: sea.dark }}>เปดหน /health</Button>
</div>
</CardContent></Card>
</div>
<Card className="rounded-2xl border-0"><CardContent className="p-5">
<div className="flex items-center justify-between mb-3"><div className="font-semibold" style={{ color: sea.textDark }}>จกรรมลาส</div><div className="flex gap-2"><Tag>Admin</Tag><Tag>Editor</Tag><Tag>Viewer</Tag></div></div>
<div className="grid md:grid-cols-2 xl:grid-cols-4 gap-3">
{recent.map((r)=> (
<div key={r.code} className="rounded-2xl p-4 border hover:shadow-sm transition" style={{ background: "white", borderColor: "#efefef" }}>
<div className="text-xs opacity-70">{r.type} {r.code}</div>
<div className="font-medium mt-1" style={{ color: sea.textDark }}>{r.title}</div>
<div className="text-xs mt-2 opacity-70">{r.who}</div>
<div className="text-xs opacity-70">{r.when}</div>
</div>
))}
</div>
</CardContent></Card>
</TabsContent>
<TabsContent value="reports" className="mt-4">
<div className="grid lg:grid-cols-2 gap-4">
<Card className="rounded-2xl border-0"><CardContent className="p-5"><div className="font-semibold mb-2" style={{ color: sea.textDark }}>Report A: RFA Drawings Revisions</div><div className="text-sm opacity-70">รวมท Drawing Revision + Code</div><div className="mt-3"><Button className="rounded-2xl btn-sea">Export CSV</Button></div></CardContent></Card>
<Card className="rounded-2xl border-0"><CardContent className="p-5"><div className="font-semibold mb-2" style={{ color: sea.textDark }}>Report B: ไทมไลน RFA vs Drawing Rev</div><div className="text-sm opacity-70"> Query #2 กำหนดไว</div><div className="mt-3"><Button className="rounded-2xl btn-sea">รายงาน</Button></div></CardContent></Card>
</div>
</TabsContent>
</Tabs>
<div className="text-xs opacity-70 text-center py-6">Sea-themed Dashboard Sidebar อนได RBAC แสดง/อน Faceted search KPI click-through Preview drawer Column visibility/Density</div>
</main>
</div>
{/* Drawer */}
<PreviewDrawer open={previewOpen} onClose={()=>setPreviewOpen(false)}>
<div className="space-y-2 text-sm">
<div><span className="opacity-70">รห:</span> RFA-LCP3-0013</div>
<div><span className="opacity-70">อเรอง:</span> นยนรายละเอยดทอระบายน</div>
<div><span className="opacity-70">สถานะ:</span> <Tag>Pending</Tag></div>
<div><span className="opacity-70">แนบไฟล:</span> 2 รายการ (PDF, DWG)</div>
<div className="pt-2 flex gap-2">
{can(user,'rfa:create') && <Button className="btn-sea rounded-xl">แกไข</Button>}
<Button variant="outline" className="rounded-xl" style={{ borderColor: sea.mid, color: sea.dark }}>เปดเตมหน</Button>
</div>
</div>
</PreviewDrawer>
<style jsx global>{`
.btn-sea { background: ${sea.dark}; }
.btn-sea:hover { background: ${sea.mid}; }
.menu-sea { background: ${sea.dark}; }
.menu-sea:hover { background: ${sea.mid}; }
`}</style>
</div>
</TooltipProvider>
);
}
งสำคญในไฟล:
Topbar: System (Admin/Users/Health/Workflow) + New (RFA/Transmittal/Correspondence/Upload Drawing) อน/ดตาม permissions
Sidebar: Search + วกรอง (Type/Status/Overdue) + เมนพรอม badge
Main: KPI การดคลกได sync ลเตอร, ตารางพรอม Density toggle / Column visibility, และ Preview Drawer
ใช API_BASE จาก @/lib/api (คอนเทนเนอรงค NEXT_PUBLIC_API_BASE)
"use client";
import React from "react";
import Link from "next/link";
import { motion } from "framer-motion";
import {
LayoutDashboard, FileText, Files, Send, Layers, Users, Settings, Activity,
Search, ChevronRight, ShieldCheck, Workflow, Database, Mail, Server, Shield, BookOpen,
PanelLeft, PanelRight, ChevronDown, Plus, Filter, Eye, EyeOff, SlidersHorizontal, Columns3, X, ExternalLink
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Progress } from "@/components/ui/progress";
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Switch } from "@/components/ui/switch";
import { API_BASE } from "@/lib/api";
// Sea palette
const sea = { light: "#E6F7FB", light2: "#F3FBFD", mid: "#2A7F98", dark: "#0D5C75", textDark: "#0E2932" };
// RBAC helper (client-side; backend truth comes from /auth/me)
function can(user, perm){
const set = new Set(user?.permissions || []);
return set.has(perm);
}
function Tag({ children }) {
return (
<Badge className="rounded-full px-3 py-1 text-xs border-0" style={{ background: sea.light, color: sea.dark }}>{children}</Badge>
);
}
function SidebarItem({ label, icon: Icon, active=false, badge }) {
return (
<button className={`group w-full flex items-center gap-3 rounded-2xl px-4 py-3 text-left transition-all border ${active?"bg-white/70":"bg-white/30 hover:bg-white/60"}`} style={{ borderColor: "#ffffff40", color: sea.textDark }}>
<Icon className="h-5 w-5" />
<span className="grow font-medium">{label}</span>
{badge ? <span className="text-xs rounded-full px-2 py-0.5" style={{ background: sea.light, color: sea.dark }}>{badge}</span> : null}
<ChevronRight className="h-4 w-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
);
}
function KPI({ label, value, icon: Icon, onClick }) {
return (
<Card onClick={onClick} className="rounded-2xl shadow-sm border-0 cursor-pointer hover:shadow transition" style={{ background: "white" }}>
<CardContent className="p-5">
<div className="flex items-start justify-between">
<span className="text-sm opacity-70">{label}</span>
<div className="rounded-xl p-2" style={{ background: sea.light }}>
<Icon className="h-5 w-5" style={{ color: sea.dark }} />
</div>
</div>
<div className="mt-3 text-3xl font-bold" style={{ color: sea.textDark }}>{value}</div>
<div className="mt-2"><Progress value={Math.min(100, (value/400)*100)} /></div>
</CardContent>
</Card>
);
}
function PreviewDrawer({ open, onClose, children }) {
return (
<div className={`fixed top-0 right-0 h-full w-full sm:w-[420px] bg-white shadow-2xl transition-transform z-50 ${open ? "translate-x-0" : "translate-x-full"}`}>
<div className="flex items-center justify-between p-4 border-b">
<div className="font-medium">รายละเอยด</div>
<Button variant="ghost" size="icon" onClick={onClose}><X className="h-5 w-5"/></Button>
</div>
<div className="p-4 overflow-auto h-[calc(100%-56px)]">{children}</div>
</div>
);
}
export default function DashboardPage(){
// ดึง user จริงจาก /auth/me (layout ฝั่ง SSR ตรวจสิทธิ์แล้ว)
const [user, setUser] = React.useState(null);
const [sidebarOpen, setSidebarOpen] = React.useState(true);
const [densityCompact, setDensityCompact] = React.useState(false);
const [showCols, setShowCols] = React.useState({ type:true, id:true, title:true, status:true, due:true, owner:true, actions:true });
const [previewOpen, setPreviewOpen] = React.useState(false);
const [filters, setFilters] = React.useState({ type:"All", status:"All", overdue:false });
const [activeQuery, setActiveQuery] = React.useState({});
React.useEffect(()=>{
fetch(`${API_BASE}/auth/me`, { credentials: "include" })
.then(r=> r.ok ? r.json() : null)
.then(data => setUser(data?.user || null))
.catch(()=> setUser(null));
},[]);
const quickLinks = [
{ label: "สร้าง RFA", icon: FileText, perm: "rfa:create", href: "/rfas/new" },
{ label: "อัปโหลด Drawing", icon: Layers, perm: "drawing:upload", href: "/drawings/upload" },
{ label: "สร้าง Transmittal", icon: Send, perm: "transmittal:create", href: "/transmittals/new" },
{ label: "บันทึกหนังสือสื่อสาร", icon: Mail, perm: "correspondence:create", href: "/correspondences/new" },
];
const nav = [
{ label: "แดชบอร์ด", icon: LayoutDashboard },
{ label: "Drawings", icon: Layers },
{ label: "RFAs", icon: FileText },
{ label: "Transmittals", icon: Send },
{ label: "Contracts & Volumes", icon: BookOpen },
{ label: "Correspondences", icon: Files },
{ label: "ผู้ใช้/บทบาท", icon: Users, perm: "users:manage" },
{ label: "Reports", icon: Activity },
{ label: "Workflow (n8n)", icon: Workflow, perm: "workflow:view" },
{ label: "Health", icon: Server, perm: "health:view" },
{ label: "Admin", icon: Settings, perm: "admin:view" },
];
const kpis = [
{ key: "rfa-pending", label: "RFAs รออนุมัติ", value: 12, icon: FileText, query: { type: "RFA", status: "pending" } },
{ key: "drawings", label: "แบบ (Drawings) ล่าสุด", value: 326, icon: Layers, query: { type: "Drawing" } },
{ key: "trans-month", label: "Transmittals เดือนนี้", value: 18, icon: Send, query: { type: "Transmittal", month: "current" } },
{ key: "overdue", label: "เกินกำหนด (Overdue)", value: 5, icon: Activity, query: { overdue: true } },
];
const recent = [
{ type: "RFA", code: "RFA-LCP3-0012", title: "ปรับปรุงรายละเอียดเสาเข็มท่าเรือ", who: "สุรเชษฐ์ (Editor)", when: "เมื่อวาน 16:40" },
{ type: "Drawing", code: "DWG-C-210A-Rev.3", title: "แปลนโครงสร้างท่าเรือส่วนที่ 2", who: "วรวิชญ์ (Admin)", when: "วันนี้ 09:15" },
{ type: "Transmittal", code: "TR-2025-0916-04", title: "ส่งแบบ Rebar Shop Drawing ชุด A", who: "Supansa (Viewer)", when: "16 ก.ย. 2025" },
{ type: "Correspondence", code: "CRSP-58", title: "แจ้งเลื่อนประชุมตรวจแบบ", who: "Kitti (Editor)", when: "15 ก.ย. 2025" },
];
const items = [
{t:"RFA", id:"RFA-LCP3-0013", title:"ยืนยันรายละเอียดท่อระบายน้ำ", status:"Pending", due:"20 ก.ย. 2025", owner:"คุณแดง"},
{t:"Drawing", id:"DWG-S-115-Rev.1", title:"Section เสาเข็มพื้นที่ส่วนที่ 1", status:"Review", due:"19 ก.ย. 2025", owner:"วิทยา"},
{t:"Transmittal", id:"TR-2025-0915-03", title:"ส่งแบบโครงสร้างท่าเรือ ชุด B", status:"Sent", due:"—", owner:"สุธิดา"},
];
const visibleItems = items.filter(r => {
if (filters.type !== "All" && r.t !== filters.type) return false;
if (filters.status !== "All" && r.status !== filters.status) return false;
if (filters.overdue && r.due === "—") return false;
return true;
});
const onKpiClick = (q) => {
setActiveQuery(q);
if (q?.type) setFilters(f => ({...f, type:q.type}));
if (q?.overdue) setFilters(f => ({...f, overdue:true}));
};
return (
<TooltipProvider>
<div className="min-h-screen" style={{ background: `linear-gradient(180deg, ${sea.light2} 0%, ${sea.light} 100%)` }}>
{/* Top System + Quick Actions */}
<header className="sticky top-0 z-40 backdrop-blur-md border-b" style={{ borderColor: "#ffffff66", background: "rgba(230,247,251,0.7)" }}>
<div className="mx-auto max-w-7xl px-4 py-2 flex items-center gap-3">
<button className="h-9 w-9 rounded-2xl flex items-center justify-center shadow-sm" style={{ background: sea.dark }} onClick={()=>setSidebarOpen(v=>!v)} aria-label={sidebarOpen?"ซ่อนแถบด้านข้าง":"แสดงแถบด้านข้าง"}>
{sidebarOpen ? <PanelLeft className="h-5 w-5 text-white"/> : <PanelRight className="h-5 w-5 text-white"/>}
</button>
<div>
<div className="text-xs opacity-70">Document Management System</div>
<div className="font-semibold" style={{ color: sea.textDark }}>โครงการพฒนาทาเรอแหลมฉบ ระยะท 3 วนท 14</div>
</div>
<Tag>Phase 3</Tag><Tag>Port Infrastructure</Tag>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="ml-auto rounded-2xl btn-sea flex items-center gap-2">System <ChevronDown className="h-4 w-4"/></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-56">
<DropdownMenuLabel>ระบบ</DropdownMenuLabel>
{can(user,'admin:view') && <DropdownMenuItem><Settings className="h-4 w-4 mr-2"/> Admin</DropdownMenuItem>}
{can(user,'users:manage') && <DropdownMenuItem><Users className="h-4 w-4 mr-2"/> ใช/บทบาท</DropdownMenuItem>}
{can(user,'health:view') && <DropdownMenuItem asChild><a href="/health" className="flex items-center w-full"><Server className="h-4 w-4 mr-2"/> Health <ExternalLink className="h-3 w-3 ml-auto"/></a></DropdownMenuItem>}
{can(user,'workflow:view') && <DropdownMenuItem asChild><a href="/workflow" className="flex items-center w-full"><Workflow className="h-4 w-4 mr-2"/> Workflow (n8n) <ExternalLink className="h-3 w-3 ml-auto"/></a></DropdownMenuItem>}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="rounded-2xl btn-sea ml-2"><Plus className="h-4 w-4 mr-1"/> New</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{quickLinks.map(({label, icon:Icon, perm, href}) => (
can(user,perm) ? (
<DropdownMenuItem key={label} asChild>
<Link href={href} className="flex items-center"><Icon className="h-4 w-4 mr-2"/>{label}</Link>
</DropdownMenuItem>
) : (
<Tooltip key={label}>
<TooltipTrigger asChild>
<div className="px-2 py-1.5 text-sm opacity-40 cursor-not-allowed flex items-center"><Icon className="h-4 w-4 mr-2"/>{label}</div>
</TooltipTrigger>
<TooltipContent>ไมทธใชงาน ({perm})</TooltipContent>
</Tooltip>
)
))}
<DropdownMenuSeparator />
<DropdownMenuItem><Layers className="h-4 w-4 mr-2"/> Import / Bulk upload</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<div className="mx-auto max-w-7xl px-4 py-6 grid grid-cols-12 gap-6">
{/* Sidebar */}
{sidebarOpen && (
<aside className="col-span-12 lg:col-span-3 xl:col-span-3">
<div className="rounded-3xl p-4 border" style={{ background: "rgba(255,255,255,0.7)", borderColor: "#ffffff66" }}>
<div className="mb-3 flex items-center gap-2">
<ShieldCheck className="h-5 w-5" style={{ color: sea.dark }} />
<div className="text-sm">RBAC: <span className="font-medium">{user?.role || '—'}</span></div>
</div>
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 opacity-70" />
<Input placeholder="ค้นหา RFA / Drawing / Transmittal / Code…" className="pl-9 rounded-2xl border-0 bg-white"/>
</div>
<div className="rounded-2xl p-3 border mb-3" style={{ borderColor: "#eef6f8", background: "#ffffffaa" }}>
<div className="text-xs font-medium mb-2">วกรอง</div>
<div className="grid grid-cols-2 gap-2">
<select className="rounded-xl border p-2 text-sm" value={filters.type} onChange={e=>setFilters(f=>({...f,type:e.target.value}))}>
<option>All</option><option>RFA</option><option>Drawing</option><option>Transmittal</option><option>Correspondence</option>
</select>
<select className="rounded-xl border p-2 text-sm" value={filters.status} onChange={e=>setFilters(f=>({...f,status:e.target.value}))}>
<option>All</option><option>Pending</option><option>Review</option><option>Sent</option>
</select>
<label className="col-span-2 flex items-center gap-2 text-sm">
<Switch checked={filters.overdue} onCheckedChange={(v)=>setFilters(f=>({...f, overdue:v}))}/> แสดงเฉพาะ Overdue
</label>
</div>
<div className="mt-2 flex gap-2">
<Button size="sm" variant="outline" className="rounded-xl" style={{ borderColor: sea.mid, color: sea.dark }}><Filter className="h-4 w-4 mr-1"/>Apply</Button>
<Button size="sm" variant="ghost" className="rounded-xl" onClick={()=>setFilters({type:"All",status:"All",overdue:false})}>Reset</Button>
</div>
</div>
<div className="space-y-2">
{nav.filter(item => !item.perm || can(user,item.perm)).map((n,i)=> (
<SidebarItem key={n.label} label={n.label} icon={n.icon} active={i===0} badge={n.label === "RFAs" ? 12 : undefined} />
))}
</div>
<div className="mt-5 text-xs opacity-70 flex items-center gap-2">
<Database className="h-4 w-4"/> dms_db MariaDB 10.11
</div>
</div>
</aside>
)}
{/* Content */}
<main className={`col-span-12 ${sidebarOpen?"lg:col-span-9 xl:col-span-9":""} space-y-6`}>
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.05, duration: 0.4 }}>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
{kpis.map((k)=> <KPI key={k.key} {...k} onClick={()=>onKpiClick(k.query)} />)}
</div>
</motion.div>
<div className="flex items-center justify-between">
<div className="text-sm opacity-70">ผลลพธจากตวกรอง: {filters.type}/{filters.status}{filters.overdue?" • Overdue":""}</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="rounded-xl" style={{ borderColor: sea.mid, color: sea.dark }} onClick={()=>setDensityCompact(v=>!v)}>
<SlidersHorizontal className="h-4 w-4 mr-1"/> Density: {densityCompact?"Compact":"Comfort"}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" className="rounded-xl" style={{ borderColor: sea.mid, color: sea.dark }}>
<Columns3 className="h-4 w-4 mr-1"/> Columns
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{Object.keys(showCols).map(key => (
<DropdownMenuItem key={key} onClick={() => setShowCols(s=>({...s, [key]: !s[key]}))}>
{(showCols[key]?<Eye className="h-4 w-4 mr-2"/>:<EyeOff className="h-4 w-4 mr-2"/>)}{key}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<Card className="rounded-2xl border-0"><CardContent className="p-0">
<div className="overflow-x-auto">
<table className={`min-w-full text-sm ${densityCompact?"[&_*]:py-1":""}`}>
<thead className="sticky top-[56px] z-10" style={{ background:"white", borderBottom:"1px solid #efefef" }}>
<tr className="text-left">
{showCols.type && <th className="py-2 px-3">ประเภท</th>}
{showCols.id && <th className="py-2 px-3">รห</th>}
{showCols.title && <th className="py-2 px-3">อเรอง</th>}
{showCols.status && <th className="py-2 px-3">สถานะ</th>}
{showCols.due && <th className="py-2 px-3">กำหนดส</th>}
{showCols.owner && <th className="py-2 px-3">บผดชอบ</th>}
{showCols.actions && <th className="py-2 px-3">ดการ</th>}
</tr>
</thead>
<tbody>
{visibleItems.length === 0 && (<tr><td className="py-8 px-3 text-center opacity-70" colSpan={7}>ไมพบรายการตามตวกรองทเลอก</td></tr>)}
{visibleItems.map(row => (
<tr key={row.id} className="border-b hover:bg-gray-50/50 cursor-pointer" style={{ borderColor: "#f3f3f3" }} onClick={()=>setPreviewOpen(true)}>
{showCols.type && <td className="py-2 px-3">{row.t}</td>}
{showCols.id && <td className="py-2 px-3 font-mono">{row.id}</td>}
{showCols.title && <td className="py-2 px-3">{row.title}</td>}
{showCols.status && <td className="py-2 px-3"><Tag>{row.status}</Tag></td>}
{showCols.due && <td className="py-2 px-3">{row.due}</td>}
{showCols.owner && <td className="py-2 px-3">{row.owner}</td>}
{showCols.actions && <td className="py-2 px-3"><div className="flex gap-2"><Button size="sm" className="rounded-xl btn-sea">เป</Button><Button size="sm" variant="outline" className="rounded-xl" style={{ borderColor: sea.mid, color: sea.dark }}>Assign</Button></div></td>}
</tr>
))}
</tbody>
</table>
</div>
<div className="px-4 py-2 text-xs opacity-70 border-t" style={{ borderColor: "#efefef"}}>เคลดล: ใช / เลอนแถว, Enter เป, / </div>
</CardContent></Card>
<Tabs defaultValue="overview" className="w-full">
<TabsList className="rounded-2xl border bg-white/80" style={{ borderColor: "#ffffff80" }}>
<TabsTrigger value="overview">ภาพรวม</TabsTrigger>
<TabsTrigger value="reports">รายงาน</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4 space-y-4">
<div className="grid lg:grid-cols-5 gap-4">
<Card className="rounded-2xl border-0 lg:col-span-3"><CardContent className="p-5">
<div className="flex items-center justify-between"><div className="font-semibold" style={{ color: sea.textDark }}>สถานะโครงการ</div><Tag>Phase 3 วนท 14</Tag></div>
<div className="mt-4 space-y-3">
<div><div className="text-sm opacity-70">ความคบหนาโดยรวม</div><Progress value={62} /></div>
<div className="grid grid-cols-3 gap-3">
<div className="rounded-xl p-4 border" style={{ background: sea.light, borderColor: sea.light }}><div className="text-xs opacity-70">วนท 1</div><div className="text-lg font-semibold">เสร 70%</div></div>
<div className="rounded-xl p-4 border" style={{ background: sea.light, borderColor: sea.light }}><div className="text-xs opacity-70">วนท 2</div><div className="text-lg font-semibold">เสร 58%</div></div>
<div className="rounded-xl p-4 border" style={{ background: sea.light, borderColor: sea.light }}><div className="text-xs opacity-70">วนท 34</div><div className="text-lg font-semibold">เสร 59%</div></div>
</div>
</div>
</CardContent></Card>
<Card className="rounded-2xl border-0 lg:col-span-2"><CardContent className="p-5 space-y-3">
<div className="flex items-center justify-between"><div className="font-semibold" style={{ color: sea.textDark }}>System Health</div><Tag>QNAP Container Station</Tag></div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2"><Server className="h-4 w-4"/> Nginx Reverse Proxy <span className="ml-auto font-medium" style={{ color: sea.dark }}>Healthy</span></div>
<div className="flex items-center gap-2"><Database className="h-4 w-4"/> MariaDB 10.11 <span className="ml-auto font-medium" style={{ color: sea.dark }}>OK</span></div>
<div className="flex items-center gap-2"><Workflow className="h-4 w-4"/> n8n (Postgres) <span className="ml-auto font-medium" style={{ color: sea.dark }}>OK</span></div>
<div className="flex items-center gap-2"><Shield className="h-4 w-4"/> RBAC Enforcement <span className="ml-auto font-medium" style={{ color: sea.dark }}>Enabled</span></div>
</div>
<div className="pt-2 border-t" style={{ borderColor: "#eeeeee" }}>
<Button variant="outline" className="rounded-xl" style={{ borderColor: sea.mid, color: sea.dark }}>เปดหน /health</Button>
</div>
</CardContent></Card>
</div>
<Card className="rounded-2xl border-0"><CardContent className="p-5">
<div className="flex items-center justify-between mb-3"><div className="font-semibold" style={{ color: sea.textDark }}>จกรรมลาส</div><div className="flex gap-2"><Tag>Admin</Tag><Tag>Editor</Tag><Tag>Viewer</Tag></div></div>
<div className="grid md:grid-cols-2 xl:grid-cols-4 gap-3">
{recent.map((r)=> (
<div key={r.code} className="rounded-2xl p-4 border hover:shadow-sm transition" style={{ background: "white", borderColor: "#efefef" }}>
<div className="text-xs opacity-70">{r.type} {r.code}</div>
<div className="font-medium mt-1" style={{ color: sea.textDark }}>{r.title}</div>
<div className="text-xs mt-2 opacity-70">{r.who}</div>
<div className="text-xs opacity-70">{r.when}</div>
</div>
))}
</div>
</CardContent></Card>
</TabsContent>
<TabsContent value="reports" className="mt-4">
<div className="grid lg:grid-cols-2 gap-4">
<Card className="rounded-2xl border-0"><CardContent className="p-5"><div className="font-semibold mb-2" style={{ color: sea.textDark }}>Report A: RFA Drawings Revisions</div><div className="text-sm opacity-70">รวมท Drawing Revision + Code</div><div className="mt-3"><Button className="rounded-2xl btn-sea">Export CSV</Button></div></CardContent></Card>
<Card className="rounded-2xl border-0"><CardContent className="p-5"><div className="font-semibold mb-2" style={{ color: sea.textDark }}>Report B: ไทมไลน RFA vs Drawing Rev</div><div className="text-sm opacity-70"> Query #2 กำหนดไว</div><div className="mt-3"><Button className="rounded-2xl btn-sea">รายงาน</Button></div></CardContent></Card>
</div>
</TabsContent>
</Tabs>
<div className="text-xs opacity-70 text-center py-6">Sea-themed Dashboard Sidebar อนได RBAC แสดง/อน Faceted search KPI click-through Preview drawer Column visibility/Density</div>
</main>
</div>
{/* Drawer */}
<PreviewDrawer open={previewOpen} onClose={()=>setPreviewOpen(false)}>
<div className="space-y-2 text-sm">
<div><span className="opacity-70">รห:</span> RFA-LCP3-0013</div>
<div><span className="opacity-70">อเรอง:</span> นยนรายละเอยดทอระบายน</div>
<div><span className="opacity-70">สถานะ:</span> <Tag>Pending</Tag></div>
<div><span className="opacity-70">แนบไฟล:</span> 2 รายการ (PDF, DWG)</div>
<div className="pt-2 flex gap-2">
{can(user,'rfa:create') && <Button className="btn-sea rounded-xl">แกไข</Button>}
<Button variant="outline" className="rounded-xl" style={{ borderColor: sea.mid, color: sea.dark }}>เปดเตมหน</Button>
</div>
</div>
</PreviewDrawer>
<style jsx global>{`
.btn-sea { background: ${sea.dark}; }
.btn-sea:hover { background: ${sea.mid}; }
.menu-sea { background: ${sea.dark}; }
.menu-sea:hover { background: ${sea.mid}; }
`}</style>
</div>
</TooltipProvider>
);
}
งสำคญในไฟล:
Topbar: System (Admin/Users/Health/Workflow) + New (RFA/Transmittal/Correspondence/Upload Drawing) อน/ดตาม permissions
Sidebar: Search + วกรอง (Type/Status/Overdue) + เมนพรอม badge
Main: KPI การดคลกได sync ลเตอร, ตารางพรอม Density toggle / Column visibility, และ Preview Drawer
ใช API_BASE จาก @/lib/api (คอนเทนเนอรงค NEXT_PUBLIC_API_BASE)
คงธมโทนนำทะเลเข/อนตามทกำหน

12
frontend/postcss.config.js Executable file → Normal file
View File

@@ -1,6 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

2
frontend/styles/global.css Executable file → Normal file
View File

@@ -1,2 +1,2 @@
html, body { height: 100%; }
html, body { height: 100%; }
* { box-sizing: border-box; }

0
frontend/tailwind.config.js Executable file → Normal file
View File