Apply .gitignore cleanup
This commit is contained in:
16
frontend/.dockerignore
Executable file → Normal file
16
frontend/.dockerignore
Executable file → Normal 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
14
frontend/api/health/route.js
Executable file → Normal 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
0
frontend/app/(auth)/login/page copy.jsx
Executable file → Normal file
0
frontend/app/(auth)/login/page.jsx
Executable file → Normal file
0
frontend/app/(auth)/login/page.jsx
Executable file → Normal file
4
frontend/app/(protected)/contracts-volumes/page.jsx
Executable file → Normal file
4
frontend/app/(protected)/contracts-volumes/page.jsx
Executable file → Normal 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
4
frontend/app/(protected)/correspondences/new/page.jsx
Executable file → Normal 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
4
frontend/app/(protected)/correspondences/page.jsx
Executable file → Normal 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
308
frontend/app/(protected)/dashboard/page.jsx
Executable file → Normal 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
8
frontend/app/(protected)/drawings/page.jsx
Executable file → Normal 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
4
frontend/app/(protected)/drawings/upload/page.jsx
Executable file → Normal 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
4
frontend/app/(protected)/health/page.jsx
Executable file → Normal 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
170
frontend/app/(protected)/layout.jsx
Executable file → Normal 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
4
frontend/app/(protected)/reports/page.jsx
Executable file → Normal 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
218
frontend/app/(protected)/rfas/new/page.jsx
Executable file → Normal 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
268
frontend/app/(protected)/rfas/page.jsx
Executable file → Normal 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
214
frontend/app/(protected)/transmittals/new/page.jsx
Executable file → Normal 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
190
frontend/app/(protected)/transmittals/page.jsx
Executable file → Normal 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
4
frontend/app/(protected)/users/page.jsx
Executable file → Normal 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
4
frontend/app/(protected)/workflow/page.jsx
Executable file → Normal 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
284
frontend/app/globals.css
Executable file → Normal 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
312
frontend/app/layout.jsx
Executable file → Normal 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
128
frontend/app/page.jsx
Executable file → Normal 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
0
frontend/components/ui/badge.jsx
Executable file → Normal file
0
frontend/components/ui/button.jsx
Executable file → Normal file
0
frontend/components/ui/button.jsx
Executable file → Normal file
0
frontend/components/ui/card.jsx
Executable file → Normal file
0
frontend/components/ui/card.jsx
Executable file → Normal file
0
frontend/components/ui/dropdown-menu.jsx
Executable file → Normal file
0
frontend/components/ui/dropdown-menu.jsx
Executable file → Normal file
0
frontend/components/ui/input.jsx
Executable file → Normal file
0
frontend/components/ui/input.jsx
Executable file → Normal file
0
frontend/components/ui/progress.jsx
Executable file → Normal file
0
frontend/components/ui/progress.jsx
Executable file → Normal file
0
frontend/components/ui/switch.jsx
Executable file → Normal file
0
frontend/components/ui/switch.jsx
Executable file → Normal file
0
frontend/components/ui/tabs.jsx
Executable file → Normal file
0
frontend/components/ui/tabs.jsx
Executable file → Normal file
0
frontend/components/ui/tooltip.jsx
Executable file → Normal file
0
frontend/components/ui/tooltip.jsx
Executable file → Normal file
18
frontend/jsconfig.json
Executable file → Normal file
18
frontend/jsconfig.json
Executable file → Normal 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
78
frontend/lib/api.js
Executable file → Normal 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
76
frontend/lib/auth.js
Executable file → Normal 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
16
frontend/lib/rbac.js
Executable file → Normal 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
12
frontend/lib/utils.js
Executable file → Normal 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
122
frontend/middleware.ts
Executable file → Normal 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
52
frontend/next.config.js
Executable file → Normal 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
14998
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
84
frontend/package.json
Executable file → Normal file
84
frontend/package.json
Executable file → Normal 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
798
frontend/page.jsx
Executable file → Normal 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 — ส่วนที่ 1–4</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 • ส่วนที่ 1–4</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">ส่วนที่ 3–4</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 — ส่วนที่ 1–4</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 • ส่วนที่ 1–4</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">ส่วนที่ 3–4</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
12
frontend/postcss.config.js
Executable file → Normal file
@@ -1,6 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
|
||||
2
frontend/styles/global.css
Executable file → Normal file
2
frontend/styles/global.css
Executable file → Normal 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
0
frontend/tailwind.config.js
Executable file → Normal file
Reference in New Issue
Block a user