build frontend ใหม่ ผ่านทั้ง dev และ proc

This commit is contained in:
2025-09-30 14:04:48 +07:00
parent 60ea49ac4f
commit 83fc120885
55 changed files with 13527 additions and 44526 deletions

6
.gitignore vendored
View File

@@ -14,9 +14,9 @@ Documents/
/frontend/node_modules/
**/node_modules/
# lockfiles
/backend/package-lock.json
/frontend/package-lock.json
**/package-lock.json
# /backend/package-lock.json
# /frontend/package-lock.json
# **/package-lock.json
# =====================================================
# Next.js build output
# =====================================================

View File

@@ -1,4 +1,4 @@
[/dms]
max_log = 361676
number = 5
max_log = 491862
number = 1
finish = 1

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

0
[frontend_prod_image Normal file
View File

View File

@@ -5,6 +5,9 @@ services:
# context: ./frontend
dockerfile: Dockerfile
target: dev
args:
- NEXT_PUBLIC_API_BASE=https://lcbp3.np-dms.work
- NODE_ENV=development
image: dms-frontend:dev
command: ["true"]
@@ -14,18 +17,18 @@ services:
# context: ./frontend
args:
- NEXT_PUBLIC_API_BASE=https://lcbp3.np-dms.work
- NODE_ENV=production #added
- NODE_ENV=production
dockerfile: Dockerfile
target: prod
# environment:
# - NEXT_PUBLIC_API_BASE=https://lcbp3.np-dms.work
image: dms-frontend:prod
command: ["true"]
environment:
- NEXT_PUBLIC_API_BASE=https://lcbp3.np-dms.work
- NODE_ENV=production
# docker compose -f docker-frontend-build.yml build --no-cache
# **** สำหรับ build บน server เอา ## ออก *****
# สร้าง package-lock.json
# cd frontend
# docker run --rm -v "$PWD:/app" -w /app node:24-alpine npm install
# สำหรับ build บน local
# cd frontend
# docker build -t dms-frontend:dev --target dev .

View File

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

102
frontend/Dockerfile Executable file → Normal file
View File

@@ -1,11 +1,12 @@
# syntax=docker/dockerfile:1.6
############ Base ############
FROM node:20-alpine AS base
FROM node:24-alpine AS base
WORKDIR /app
RUN apk add --no-cache bash curl tzdata \
&& ln -snf /usr/share/zoneinfo/Asia/Bangkok /etc/localtime \
&& echo "Asia/Bangkok" > /etc/timezone
ARG NEXT_PUBLIC_API_BASE
ENV TZ=Asia/Bangkok \
NEXT_TELEMETRY_DISABLED=1 \
@@ -13,62 +14,109 @@ ENV TZ=Asia/Bangkok \
WATCHPACK_POLLING=true \
NEXT_PUBLIC_API_BASE=${NEXT_PUBLIC_API_BASE} \
npm_config_yes=true
# (ค่าพวกนี้ซ้ำกับ compose ได้ ไม่เป็นปัญหา)
# สร้างโฟลเดอร์ที่ Next.js ต้องเขียนทับ และกำหนดสิทธิ์
RUN mkdir -p /app/.next/cache /app/.next/server /app/.next/types && \
chmod -R 777 /app/.next
############ Deps (install) ############
FROM base AS deps
## COPY package.json package-lock.json* ./
COPY package.json package-lock.json* ./
# ถ้ามี lock ใช้ npm ci; ถ้าไม่มีก็ npm i
## RUN if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; else npm install --no-audit --no-fund; fi
COPY package.json ./
# ไม่ copy lock เพื่อบังคับให้ใช้ npm install
RUN npm install --no-audit --no-fund
# เพิ่ม shadcn/ui + tailwind deps
# RUN npm install -D tailwindcss postcss autoprefixer shadcn@latest \
# && npm install class-variance-authority clsx framer-motion lucide-react tailwind-merge tailwindcss-animate
# init tailwind config (กัน No Tailwind CSS configuration found)
# RUN npx tailwindcss init -p
# bake components ของ shadcn แบบ non-interactive
# RUN npx shadcn add -y button badge card input tabs progress dropdown-menu tooltip switch
RUN if [ -f package-lock.json ]; then \
npm ci --no-audit --no-fund; \
else \
npm install --no-audit --no-fund; \
fi
############ Dev (hot-reload) ############
# ใช้ร่วมกับ compose: bind ทั้งโปรเจ็กต์ → /app
# และใช้ named volume แยกสำหรับ /app/node_modules และ /app/.next
FROM base AS dev
RUN apk add --no-cache git openssh-client ca-certificates
# สำคัญ: สร้างโฟลเดอร์ที่ Next ใช้งาน (เวลายังไม่ bind อะไรเข้ามา)
# สร้างโฟลเดอร์ที่ Next ใช้งาน
RUN install -d -o node -g node /app/public /app/app /app/.logs /app/.next /app/.next/cache
# นำ node_modules จากชั้น deps มาไว้ (ลดเวลาตอน start ครั้งแรก)
# นำ node_modules จากชั้น deps มาไว้
COPY --from=deps /app/node_modules /app/node_modules
# ไม่กำหนด USER ที่นี่ ปล่อยให้ compose คุม (ตอนนี้คุณใช้ user: "1000:1000")
# สำหรับ development: คาดหวังว่าจะมี bind mount source code
# แต่ก็ COPY ไว้เผื่อรัน standalone
COPY --chown=node:node . .
ENV NODE_ENV=development
EXPOSE 3000
CMD ["npm", "run", "dev"]
############ Build (production) ############
FROM deps AS builder
# ล้างและสร้างโฟลเดอร์ใหม่ทั้งหมด
RUN rm -rf /app/.next && \
mkdir -p /app/.next && \
chmod 777 /app/.next
# Copy all necessary files for build
COPY . .
ARG NEXT_PUBLIC_API_BASE
ENV NEXT_PUBLIC_API_BASE=${NEXT_PUBLIC_API_BASE}
# Debug: Check if components exist before build
RUN echo "=== Checking components ===" && \
ls -la components/ui/ || echo "WARNING: No components/ui" && \
ls -la lib/utils.* || echo "WARNING: No lib/utils" && \
cat jsconfig.json || cat tsconfig.json || echo "WARNING: No jsconfig/tsconfig" && \
echo "=== Checking .next permissions ===" && \
ls -lad /app/.next
RUN npm run build
############ Prod runtime (optimized) ############
FROM node:20-alpine AS prod
FROM node:24-alpine AS prod
WORKDIR /app
ENV NODE_ENV=production
RUN apk add --no-cache libc6-compat
# RUN apk add --no-cache libc6-compat \
# && addgroup -g 1000 node && adduser -D -u 1000 -G node node
# คัดเฉพาะของจำเป็น
COPY --from=builder /app/package.json /app/package-lock.json* ./
# ติดตั้งเฉพาะ prod deps
RUN if [ -f package-lock.json ]; then npm ci --omit=dev --no-audit --no-fund; else npm i --omit=dev --no-audit --no-fund; fi
RUN if [ -f package-lock.json ]; then \
npm ci --omit=dev --no-audit --no-fund; \
else \
npm i --omit=dev --no-audit --no-fund; \
fi
COPY --from=builder --chown=node:node /app/.next ./.next
COPY --from=builder --chown=node:node /app/public ./public
COPY --from=builder --chown=node:node /app/next.config.* ./
RUN mkdir -p /app/.next/cache && chown -R node:node /app
USER node
EXPOSE 3000
CMD ["npm", "start"]
# === STEP 1: Generate Components ===
# cd /share/Container/dms
# UID=$(id -u) GID=$(id -g) docker compose -f generate-shadcn-components.yml run --rm setup-shadcn
# === STEP 2: ตรวจสอบไฟล์ ===
# cd frontend
# echo "📁 Components created:"
# ls -la components/ui/ 2>/dev/null || echo "⚠️ No components/ui/ directory"
# ls -la lib/ 2>/dev/null || echo "⚠️ No lib/ directory"
# ls -la components.json 2>/dev/null || echo "⚠️ No components.json"
# === STEP 3: Commit to Git ===
# git add components/ lib/ components.json
# git status
# git commit -m "Add shadcn/ui components and utilities"
# === STEP 4: Build Docker Image ===
# cd /share/Container/dms
# docker compose -f docker-frontend-build.yml build --no-cache
# docker compose -f docker-frontend-build.yml build --no-cache 2>&1 | tee build.log
# === ต่อไปที่ต้องเพิ่ม component ใหม่:
# docker run --rm -v "$PWD:/app" -w /app node:24-alpine \
# npx shadcn@latest add dialog
# แล้ว commit
# git add components/ui/dialog.jsx
# git commit -m "Add dialog component"

View File

@@ -1,70 +0,0 @@
# syntax=docker/dockerfile:1.6
############ Base ############
FROM node:20-alpine AS base
WORKDIR /app
RUN apk add --no-cache bash curl tzdata \
&& ln -snf /usr/share/zoneinfo/Asia/Bangkok /etc/localtime \
&& echo "Asia/Bangkok" > /etc/timezone
ARG NEXT_PUBLIC_API_BASE
ENV TZ=Asia/Bangkok \
NEXT_TELEMETRY_DISABLED=1 \
CHOKIDAR_USEPOLLING=true \
WATCHPACK_POLLING=true \
NEXT_PUBLIC_API_BASE=${NEXT_PUBLIC_API_BASE} \
npm_config_yes=true
# (ค่าพวกนี้ซ้ำกับ compose ได้ ไม่เป็นปัญหา)
############ Deps (install) ############
FROM base AS deps
COPY package.json package-lock.json* ./
# ถ้ามี lock ใช้ npm ci; ถ้าไม่มีก็ npm i
RUN if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; else npm i --no-audit --no-fund; fi
# เพิ่ม shadcn/ui + tailwind deps
RUN npm install -D tailwindcss postcss autoprefixer shadcn@latest \
&& npm install class-variance-authority clsx framer-motion lucide-react tailwind-merge tailwindcss-animate
# init tailwind config (กัน No Tailwind CSS configuration found)
RUN npx tailwindcss init -p
# bake components ของ shadcn แบบ non-interactive
RUN npx shadcn add -y button badge card input tabs progress dropdown-menu tooltip switch
############ Dev (hot-reload) ############
# ใช้ร่วมกับ compose: bind ทั้งโปรเจ็กต์ → /app
# และใช้ named volume แยกสำหรับ /app/node_modules และ /app/.next
FROM base AS dev
RUN apk add --no-cache git openssh-client ca-certificates
# สำคัญ: สร้างโฟลเดอร์ที่ Next ใช้งาน (เวลายังไม่ bind อะไรเข้ามา)
RUN install -d -o node -g node /app/public /app/app /app/.logs /app/.next /app/.next/cache
# นำ node_modules จากชั้น deps มาไว้ (ลดเวลาตอน start ครั้งแรก)
COPY --from=deps /app/node_modules /app/node_modules
# ไม่กำหนด USER ที่นี่ ปล่อยให้ compose คุม (ตอนนี้คุณใช้ user: "1000:1000")
ENV NODE_ENV=development
EXPOSE 3000
CMD ["npm", "run", "dev"]yy
############ Build (production) ############
FROM deps AS builder
COPY . .
RUN npm run build
############ Prod runtime (optimized) ############
FROM node:20-alpine AS prod
WORKDIR /app
ENV NODE_ENV=production
RUN apk add --no-cache libc6-compat
# RUN apk add --no-cache libc6-compat \
# && addgroup -g 1000 node && adduser -D -u 1000 -G node node
# คัดเฉพาะของจำเป็น
COPY --from=builder /app/package.json /app/package-lock.json* ./
# ติดตั้งเฉพาะ prod deps
RUN if [ -f package-lock.json ]; then npm ci --omit=dev --no-audit --no-fund; else npm i --omit=dev --no-audit --no-fund; fi
COPY --from=builder --chown=node:node /app/.next ./.next
COPY --from=builder --chown=node:node /app/public ./public
USER node
EXPOSE 3000
CMD ["npm", "start"]
# docker compose -f docker-frontend-build.yml build --no-cache

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

@@ -1,8 +1,16 @@
// frontend/app/(auth)/login/page.jsx
// File: frontend/app/(auth)/login/page
"use client";
import { useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
// ✅ ปรับให้ตรง backend: ใช้ Bearer token (ไม่ใช้ cookie)
// - เรียก POST /api/auth/login → รับ { token, refresh_token, user }
// - เก็บ token/refresh_token ใน localStorage (หรือ sessionStorage ถ้าไม่ติ๊กจำไว้)
// - ไม่ใช้ credentials: "include" อีกต่อไป
// - เอา RootLayout/metadata ออก เพราะไฟล์เพจเป็น client component
// - เพิ่มการอ่าน NEXT_PUBLIC_API_BASE และ error handling ให้ตรงกับ backend
import { useState, useMemo } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import {
Card,
CardHeader,
@@ -15,327 +23,199 @@ import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";
const IS_DEV = process.env.NODE_ENV !== "production";
// URL builder กันเคสซ้ำ /api
function buildLoginUrl() {
const base = (process.env.NEXT_PUBLIC_API_BASE || "").replace(/\/+$/, "");
if (base.endsWith("/api")) return `${base}/auth/login`;
return `${base}/api/auth/login`;
}
// helper: parse response body เป็น json หรือ text
async function parseBody(res) {
const text = await res.text();
try {
return { raw: text, json: JSON.parse(text) };
} catch {
return { raw: text, json: null };
}
}
// สร้างข้อความ debug ที่พร้อม copy
function stringifyDebug(debugInfo) {
try {
return JSON.stringify(debugInfo, null, 2);
} catch {
return String(debugInfo);
}
}
const API_BASE = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") || "";
export default function LoginPage() {
const router = useRouter();
const search = useSearchParams();
const redirectTo = search.get("from") || "/dashboard";
const searchParams = useSearchParams();
const nextPath = useMemo(
() => searchParams.get("next") || "/dashboard",
[searchParams]
);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPw, setShowPw] = useState(false);
const [remember, setRemember] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
// สำหรับ debug panel
const [debugInfo, setDebugInfo] = useState(null);
const [copyState, setCopyState] = useState({ copied: false, error: "" });
const loginUrl = useMemo(buildLoginUrl, [process.env.NEXT_PUBLIC_API_BASE]);
const [err, setErr] = useState("");
async function onSubmit(e) {
e.preventDefault();
setSubmitting(true);
setError("");
if (IS_DEV) {
setDebugInfo(null);
setCopyState({ copied: false, error: "" });
setErr("");
if (!username.trim() || !password) {
setErr("กรอกชื่อผู้ใช้และรหัสผ่านให้ครบ");
return;
}
try {
const res = await fetch(loginUrl, {
setSubmitting(true);
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username, password }),
cache: "no-store",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const body = await parseBody(res);
const apiErr = {
name: "ApiError",
status: res.status,
statusText: res.statusText,
body: body.json ?? body.raw,
message: (() => {
const msgFromJson =
(body.json && (body.json.error || body.json.message)) || null;
if (res.status === 400)
return `Bad request: ${msgFromJson ?? res.statusText}`;
if (res.status === 401)
return `Unauthenticated: ${msgFromJson ?? "Invalid credentials"}`;
if (res.status === 403)
return `Forbidden: ${msgFromJson ?? res.statusText}`;
if (res.status === 404)
return `Not found: ${msgFromJson ?? res.statusText}`;
if (res.status >= 500)
return `Server error (${res.status}): ${
msgFromJson ?? res.statusText
}`;
return `${res.status} ${res.statusText}: ${
msgFromJson ?? "Request failed"
}`;
})(),
};
if (IS_DEV) {
setDebugInfo({
kind: "api",
request: {
url: loginUrl,
method: "POST",
payload: { username: "(masked)", password: "(masked)" },
},
response: {
status: res.status,
statusText: res.statusText,
body: apiErr.body,
},
env: {
NEXT_PUBLIC_API_BASE:
process.env.NEXT_PUBLIC_API_BASE || "(unset)",
NODE_ENV: process.env.NODE_ENV,
},
});
}
throw apiErr;
}
// ✅ สำเร็จ
if (IS_DEV) {
setDebugInfo({
kind: "success",
request: { url: loginUrl, method: "POST" },
note: "Login success. Redirecting…",
});
}
router.push(redirectTo);
} catch (err) {
if (err?.name === "ApiError") {
setError(err.message);
} else if (err instanceof TypeError && /fetch/i.test(err.message)) {
setError(
"Network error: ไม่สามารถเชื่อมต่อเซิร์ฟเวอร์ได้ (ตรวจสอบ proxy/NPM/SSL)"
// รองรับข้อความ error จาก backend เช่น INVALID_CREDENTIALS
setErr(
data?.error === "INVALID_CREDENTIALS"
? "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง"
: data?.error || "เข้าสู่ระบบไม่สำเร็จ"
);
if (IS_DEV) {
setDebugInfo({
kind: "network",
request: { url: loginUrl, method: "POST" },
error: { message: err.message },
hint: "เช็คว่า NPM ชี้ proxy /api ไปที่ backend ถูก network/port, และ TLS chain ถูกต้อง",
});
}
} else {
setError(err?.message || "Unexpected error");
if (IS_DEV) {
setDebugInfo({
kind: "unknown",
request: { url: loginUrl, method: "POST" },
error: { message: String(err) },
});
}
return;
}
// ✅ เก็บ token ตามโหมดจำไว้/ไม่จำ
const storage = remember ? window.localStorage : window.sessionStorage;
storage.setItem("dms.token", data.token);
storage.setItem("dms.refresh_token", data.refresh_token);
storage.setItem("dms.user", JSON.stringify(data.user || {}));
// (ออปชัน) เผยแพร่ event ให้แท็บอื่นทราบ
try {
window.dispatchEvent(
new StorageEvent("storage", { key: "dms.auth", newValue: "login" })
);
} catch {}
router.replace(nextPath);
} catch (e) {
setErr("เชื่อมต่อเซิร์ฟเวอร์ไม่ได้ กรุณาลองใหม่");
} finally {
setSubmitting(false);
}
}
async function handleCopyDebug() {
if (!debugInfo) return;
const text = stringifyDebug(debugInfo);
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
// Fallback
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.focus();
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
setCopyState({ copied: true, error: "" });
setTimeout(() => setCopyState({ copied: false, error: "" }), 1500);
} catch (e) {
setCopyState({
copied: false,
error: "คัดลอกไม่สำเร็จ (permission ของ clipboard?)",
});
setTimeout(() => setCopyState({ copied: false, error: "" }), 2500);
}
}
return (
<Card className="mx-auto w-full max-w-md shadow-lg">
<div className="grid min-h-[calc(100vh-4rem)] place-items-center p-4">
<Card className="w-full max-w-md border-0 shadow-xl ring-1 ring-black/5 bg-white/90 backdrop-blur">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl text-sky-800">Sign in</CardTitle>
<CardDescription>
Enter your credentials to access the DMS
<CardTitle className="text-2xl font-bold text-sky-800">
เขาสระบบ
</CardTitle>
<CardDescription className="text-sky-700">
Document Management System LCBP3
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
<CardContent>
{err ? (
<Alert className="mb-4">
<AlertDescription>{err}</AlertDescription>
</Alert>
) : null}
<form onSubmit={onSubmit} className="space-y-4">
<form onSubmit={onSubmit} className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Label htmlFor="username">อผใช</Label>
<Input
id="username"
autoFocus
autoComplete="username"
placeholder="superadmin"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
placeholder="เช่น superadmin"
disabled={submitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password">รหสผาน</Label>
<div className="relative">
<Input
id="password"
type="password"
type={showPw ? "text" : "password"}
autoComplete="current-password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder="••••••••"
disabled={submitting}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPw((v) => !v)}
className="absolute inset-y-0 px-2 my-auto text-xs bg-white border rounded-md right-2 hover:bg-slate-50"
aria-label={showPw ? "ซ่อนรหัสผ่าน" : "แสดงรหัสผ่าน"}
disabled={submitting}
>
{showPw ? "Hide" : "Show"}
</button>
</div>
</div>
<Button type="submit" className="w-full" disabled={submitting}>
{submitting ? "Signing in..." : "Sign in"}
<div className="flex items-center justify-between pt-1">
<label className="inline-flex items-center gap-2 text-sm text-slate-600">
<input
type="checkbox"
className="size-4 accent-sky-700"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
disabled={submitting}
/>
จดจำฉนไวในเครองน
</label>
<a
href="/forgot-password"
className="text-sm text-sky-700 hover:text-sky-900 hover:underline"
>
มรหสผาน?
</a>
</div>
<Button
type="submit"
disabled={submitting}
className="mt-2 bg-sky-700 hover:bg-sky-800"
>
{submitting ? (
<span className="inline-flex items-center gap-2">
<Spinner /> กำลงเขาสระบบ
</span>
) : (
"เข้าสู่ระบบ"
)}
</Button>
</form>
{IS_DEV && (
<div className="mt-4 rounded-xl border border-sky-200 bg-sky-50 p-3">
<div className="mb-2 flex items-center justify-between">
<div className="text-sm font-semibold text-sky-900">
Debug (dev mode only)
</div>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="secondary"
onClick={handleCopyDebug}
disabled={!debugInfo}
aria-label="Copy debug info"
>
{copyState.copied ? "Copied!" : "Copy debug"}
</Button>
</div>
</div>
<Separator className="my-2" />
<div className="space-y-2 text-xs text-sky-900">
<div>
<span className="font-medium">Request URL:</span>{" "}
<code className="break-all">{loginUrl}</code>
</div>
{debugInfo?.request?.method && (
<div>
<span className="font-medium">Method:</span>{" "}
<code>{debugInfo.request.method}</code>
</div>
)}
{debugInfo?.response && (
<>
<div>
<span className="font-medium">Status:</span>{" "}
<code>
{debugInfo.response.status}{" "}
{debugInfo.response.statusText}
</code>
</div>
<div className="font-medium">Response body:</div>
<pre className="max-h-48 overflow-auto rounded bg-white p-2">
{typeof debugInfo.response.body === "string"
? debugInfo.response.body
: JSON.stringify(debugInfo.response.body, null, 2)}
</pre>
</>
)}
{debugInfo?.error && (
<>
<div className="font-medium">Error:</div>
<pre className="max-h-48 overflow-auto rounded bg-white p-2">
{JSON.stringify(debugInfo.error, null, 2)}
</pre>
</>
)}
{debugInfo?.env && (
<>
<div className="font-medium">Env:</div>
<pre className="max-h-40 overflow-auto rounded bg-white p-2">
{JSON.stringify(debugInfo.env, null, 2)}
</pre>
</>
)}
{debugInfo?.note && (
<div className="italic text-sky-700">{debugInfo.note}</div>
)}
{debugInfo?.hint && (
<div className="italic text-sky-700">
Hint: {debugInfo.hint}
</div>
)}
{copyState.error && (
<div className="text-red-600">{copyState.error}</div>
)}
</div>
</div>
)}
</CardContent>
<CardFooter className="justify-center text-xs text-gray-500">
<CardFooter className="text-xs text-center text-slate-500">
&copy; {new Date().getFullYear()} np-dms.work
</CardFooter>
</Card>
</div>
);
}
/** Spinner แบบไม่พึ่งไลบรารีเสริม */
function Spinner() {
return (
<svg
className="animate-spin size-4"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
/>
</svg>
);
}

View File

@@ -1,4 +1,4 @@
// File: frontend/app/(auth)/login/page· typescript
// File: frontend/app/(auth)/login/page.jsx
"use client";
@@ -9,7 +9,7 @@
// - เอา RootLayout/metadata ออก เพราะไฟล์เพจเป็น client component
// - เพิ่มการอ่าน NEXT_PUBLIC_API_BASE และ error handling ให้ตรงกับ backend
import { useState, useMemo } from "react";
import { useState, useMemo, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import {
Card,
@@ -26,7 +26,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") || "";
export default function LoginPage() {
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const nextPath = useMemo(
@@ -194,6 +194,39 @@ export default function LoginPage() {
);
}
export default function LoginPage() {
return (
<Suspense fallback={<LoginPageSkeleton />}>
<LoginForm />
</Suspense>
);
}
/** Loading skeleton */
function LoginPageSkeleton() {
return (
<div className="grid min-h-[calc(100vh-4rem)] place-items-center p-4">
<Card className="w-full max-w-md border-0 shadow-xl ring-1 ring-black/5 bg-white/90 backdrop-blur">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-sky-800">
เขาสระบบ
</CardTitle>
<CardDescription className="text-sky-700">
Document Management System LCBP3
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 animate-pulse">
<div className="h-10 rounded bg-slate-200"></div>
<div className="h-10 rounded bg-slate-200"></div>
<div className="h-10 rounded bg-slate-200"></div>
</div>
</CardContent>
</Card>
</div>
);
}
/** Spinner แบบไม่พึ่งไลบรารีเสริม */
function Spinner() {
return (

View File

@@ -66,7 +66,7 @@ const sea = {
const can = (user, perm) => new Set(user?.permissions || []).has(perm);
const Tag = ({ children }) => (
<Badge
className="rounded-full px-3 py-1 text-xs border-0"
className="px-3 py-1 text-xs border-0 rounded-full"
style={{ background: sea.light, color: sea.dark }}
>
{children}
@@ -79,8 +79,8 @@ const SidebarItem = ({ label, icon: Icon, active = false, badge }) => (
}`}
style={{ borderColor: "#ffffff40", color: sea.textDark }}
>
<Icon className="h-5 w-5" />
<span className="grow font-medium">{label}</span>
<Icon className="w-5 h-5" />
<span className="font-medium grow">{label}</span>
{badge ? (
<span
className="text-xs rounded-full px-2 py-0.5"
@@ -89,20 +89,20 @@ const SidebarItem = ({ label, icon: Icon, active = false, badge }) => (
{badge}
</span>
) : null}
<ChevronRight className="h-4 w-4 opacity-0 group-hover:opacity-100 transition-opacity" />
<ChevronRight className="w-4 h-4 transition-opacity opacity-0 group-hover:opacity-100" />
</button>
);
const KPI = ({ label, value, icon: Icon, onClick }) => (
<Card
onClick={onClick}
className="rounded-2xl shadow-sm border-0 cursor-pointer hover:shadow transition"
className="transition border-0 shadow-sm cursor-pointer rounded-2xl hover:shadow"
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 className="p-2 rounded-xl" style={{ background: sea.light }}>
<Icon className="w-5 h-5" style={{ color: sea.dark }} />
</div>
</div>
<div className="mt-3 text-3xl font-bold" style={{ color: sea.textDark }}>
@@ -124,7 +124,7 @@ function PreviewDrawer({ open, onClose, children }) {
<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" />
<X className="w-5 h-5" />
</Button>
</div>
<div className="p-4 overflow-auto h-[calc(100%-56px)]">{children}</div>
@@ -306,23 +306,23 @@ export default function DashboardPage() {
}}
>
<header
className="sticky top-0 z-40 backdrop-blur-md border-b"
className="sticky top-0 z-40 border-b backdrop-blur-md"
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">
<div className="flex items-center gap-3 px-4 py-2 mx-auto max-w-7xl">
<button
className="h-9 w-9 rounded-2xl flex items-center justify-center shadow-sm"
className="flex items-center justify-center shadow-sm h-9 w-9 rounded-2xl"
style={{ background: sea.dark }}
onClick={() => setSidebarOpen((v) => !v)}
aria-label={sidebarOpen ? "ซ่อนแถบด้านข้าง" : "แสดงแถบด้านข้าง"}
>
{sidebarOpen ? (
<PanelLeft className="h-5 w-5 text-white" />
<PanelLeft className="w-5 h-5 text-white" />
) : (
<PanelRight className="h-5 w-5 text-white" />
<PanelRight className="w-5 h-5 text-white" />
)}
</button>
<div>
@@ -338,35 +338,35 @@ export default function DashboardPage() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="ml-auto rounded-2xl btn-sea flex items-center gap-2">
System <ChevronDown className="h-4 w-4" />
<Button className="flex items-center gap-2 ml-auto rounded-2xl btn-sea">
System <ChevronDown className="w-4 h-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
<Settings className="w-4 h-4 mr-2" /> Admin
</DropdownMenuItem>
)}
{can(user, "users:manage") && (
<DropdownMenuItem>
<Users className="h-4 w-4 mr-2" /> ใช/บทบาท
<Users className="w-4 h-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" />
<Server className="w-4 h-4 mr-2" /> Health{" "}
<ExternalLink className="w-3 h-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" />
<Workflow className="w-4 h-4 mr-2" /> Workflow (n8n){" "}
<ExternalLink className="w-3 h-3 ml-auto" />
</a>
</DropdownMenuItem>
)}
@@ -375,8 +375,8 @@ export default function DashboardPage() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="rounded-2xl btn-sea ml-2">
<Plus className="h-4 w-4 mr-1" /> New
<Button className="ml-2 rounded-2xl btn-sea">
<Plus className="w-4 h-4 mr-1" /> New
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -384,7 +384,7 @@ export default function DashboardPage() {
can(user, perm) ? (
<DropdownMenuItem key={label} asChild>
<Link href={href} className="flex items-center">
<Icon className="h-4 w-4 mr-2" />
<Icon className="w-4 h-4 mr-2" />
{label}
</Link>
</DropdownMenuItem>
@@ -392,7 +392,7 @@ export default function DashboardPage() {
<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" />
<Icon className="w-4 h-4 mr-2" />
{label}
</div>
</TooltipTrigger>
@@ -404,26 +404,26 @@ export default function DashboardPage() {
)}
<DropdownMenuSeparator />
<DropdownMenuItem>
<Layers className="h-4 w-4 mr-2" /> Import / Bulk upload
<Layers className="w-4 h-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">
<div className="grid grid-cols-12 gap-6 px-4 py-6 mx-auto max-w-7xl">
{sidebarOpen && (
<aside className="col-span-12 lg:col-span-3 xl:col-span-3">
<div
className="rounded-3xl p-4 border"
className="p-4 border rounded-3xl"
style={{
background: "rgba(255,255,255,0.7)",
borderColor: "#ffffff66",
}}
>
<div className="mb-3 flex items-center gap-2">
<div className="flex items-center gap-2 mb-3">
<ShieldCheck
className="h-5 w-5"
className="w-5 h-5"
style={{ color: sea.dark }}
/>
<div className="text-sm">
@@ -432,20 +432,20 @@ export default function DashboardPage() {
</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" />
<Search className="absolute w-4 h-4 -translate-y-1/2 left-3 top-1/2 opacity-70" />
<Input
placeholder="ค้นหา RFA / Drawing / Transmittal / Code…"
className="pl-9 rounded-2xl border-0 bg-white"
className="bg-white border-0 pl-9 rounded-2xl"
/>
</div>
<div
className="rounded-2xl p-3 border mb-3"
className="p-3 mb-3 border rounded-2xl"
style={{ borderColor: "#eef6f8", background: "#ffffffaa" }}
>
<div className="text-xs font-medium mb-2">วกรอง</div>
<div className="mb-2 text-xs font-medium">วกรอง</div>
<div className="grid grid-cols-2 gap-2">
<select
className="rounded-xl border p-2 text-sm"
className="p-2 text-sm border rounded-xl"
value={filters.type}
onChange={(e) =>
setFilters((f) => ({ ...f, type: e.target.value }))
@@ -458,7 +458,7 @@ export default function DashboardPage() {
<option>Correspondence</option>
</select>
<select
className="rounded-xl border p-2 text-sm"
className="p-2 text-sm border rounded-xl"
value={filters.status}
onChange={(e) =>
setFilters((f) => ({ ...f, status: e.target.value }))
@@ -469,7 +469,7 @@ export default function DashboardPage() {
<option>Review</option>
<option>Sent</option>
</select>
<label className="col-span-2 flex items-center gap-2 text-sm">
<label className="flex items-center col-span-2 gap-2 text-sm">
<Switch
checked={filters.overdue}
onCheckedChange={(v) =>
@@ -479,14 +479,14 @@ export default function DashboardPage() {
แสดงเฉพาะ Overdue
</label>
</div>
<div className="mt-2 flex gap-2">
<div className="flex gap-2 mt-2">
<Button
size="sm"
variant="outline"
className="rounded-xl"
style={{ borderColor: sea.mid, color: sea.dark }}
>
<Filter className="h-4 w-4 mr-1" />
<Filter className="w-4 h-4 mr-1" />
Apply
</Button>
<Button
@@ -518,8 +518,8 @@ export default function DashboardPage() {
/>
))}
</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 className="flex items-center gap-2 mt-5 text-xs opacity-70">
<Database className="w-4 h-4" /> dms_db MariaDB 10.11
</div>
</div>
</aside>
@@ -535,7 +535,7 @@ export default function DashboardPage() {
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">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{kpis.map((k) => (
<KPI key={k.key} {...k} onClick={() => onKpiClick(k.query)} />
))}
@@ -555,7 +555,7 @@ export default function DashboardPage() {
style={{ borderColor: sea.mid, color: sea.dark }}
onClick={() => setDensityCompact((v) => !v)}
>
<SlidersHorizontal className="h-4 w-4 mr-1" /> Density:{" "}
<SlidersHorizontal className="w-4 h-4 mr-1" /> Density:{" "}
{densityCompact ? "Compact" : "Comfort"}
</Button>
<DropdownMenu>
@@ -566,7 +566,7 @@ export default function DashboardPage() {
className="rounded-xl"
style={{ borderColor: sea.mid, color: sea.dark }}
>
<Columns3 className="h-4 w-4 mr-1" /> Columns
<Columns3 className="w-4 h-4 mr-1" /> Columns
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -578,9 +578,9 @@ export default function DashboardPage() {
}
>
{showCols[key] ? (
<Eye className="h-4 w-4 mr-2" />
<Eye className="w-4 h-4 mr-2" />
) : (
<EyeOff className="h-4 w-4 mr-2" />
<EyeOff className="w-4 h-4 mr-2" />
)}
{key}
</DropdownMenuItem>
@@ -590,7 +590,7 @@ export default function DashboardPage() {
</div>
</div>
<Card className="rounded-2xl border-0">
<Card className="border-0 rounded-2xl">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table
@@ -606,22 +606,22 @@ export default function DashboardPage() {
}}
>
<tr className="text-left">
{showCols.type && <th className="py-2 px-3">ประเภท</th>}
{showCols.id && <th className="py-2 px-3">รห</th>}
{showCols.type && <th className="px-3 py-2">ประเภท</th>}
{showCols.id && <th className="px-3 py-2">รห</th>}
{showCols.title && (
<th className="py-2 px-3">อเรอง</th>
<th className="px-3 py-2">อเรอง</th>
)}
{showCols.status && (
<th className="py-2 px-3">สถานะ</th>
<th className="px-3 py-2">สถานะ</th>
)}
{showCols.due && (
<th className="py-2 px-3">กำหนดส</th>
<th className="px-3 py-2">กำหนดส</th>
)}
{showCols.owner && (
<th className="py-2 px-3">บผดชอบ</th>
<th className="px-3 py-2">บผดชอบ</th>
)}
{showCols.actions && (
<th className="py-2 px-3">ดการ</th>
<th className="px-3 py-2">ดการ</th>
)}
</tr>
</thead>
@@ -629,7 +629,7 @@ export default function DashboardPage() {
{visibleItems.length === 0 && (
<tr>
<td
className="py-8 px-3 text-center opacity-70"
className="px-3 py-8 text-center opacity-70"
colSpan={7}
>
ไมพบรายการตามตวกรองทเลอก
@@ -639,32 +639,32 @@ export default function DashboardPage() {
{visibleItems.map((row) => (
<tr
key={row.id}
className="border-b hover:bg-gray-50/50 cursor-pointer"
className="border-b cursor-pointer hover:bg-gray-50/50"
style={{ borderColor: "#f3f3f3" }}
onClick={() => setPreviewOpen(true)}
>
{showCols.type && (
<td className="py-2 px-3">{row.t}</td>
<td className="px-3 py-2">{row.t}</td>
)}
{showCols.id && (
<td className="py-2 px-3 font-mono">{row.id}</td>
<td className="px-3 py-2 font-mono">{row.id}</td>
)}
{showCols.title && (
<td className="py-2 px-3">{row.title}</td>
<td className="px-3 py-2">{row.title}</td>
)}
{showCols.status && (
<td className="py-2 px-3">
<td className="px-3 py-2">
<Tag>{row.status}</Tag>
</td>
)}
{showCols.due && (
<td className="py-2 px-3">{row.due}</td>
<td className="px-3 py-2">{row.due}</td>
)}
{showCols.owner && (
<td className="py-2 px-3">{row.owner}</td>
<td className="px-3 py-2">{row.owner}</td>
)}
{showCols.actions && (
<td className="py-2 px-3">
<td className="px-3 py-2">
<div className="flex gap-2">
<Button
size="sm"
@@ -692,7 +692,7 @@ export default function DashboardPage() {
</table>
</div>
<div
className="px-4 py-2 text-xs opacity-70 border-t"
className="px-4 py-2 text-xs border-t opacity-70"
style={{ borderColor: "#efefef" }}
>
เคลดล: ใช / เลอนแถว, Enter เป, /
@@ -702,15 +702,15 @@ export default function DashboardPage() {
<Tabs defaultValue="overview" className="w-full">
<TabsList
className="rounded-2xl border bg-white/80"
className="border rounded-2xl 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">
<div className="grid gap-4 lg:grid-cols-5">
<Card className="border-0 rounded-2xl lg:col-span-3">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div
@@ -730,7 +730,7 @@ export default function DashboardPage() {
</div>
<div className="grid grid-cols-3 gap-3">
<div
className="rounded-xl p-4 border"
className="p-4 border rounded-xl"
style={{
background: sea.light,
borderColor: sea.light,
@@ -742,7 +742,7 @@ export default function DashboardPage() {
</div>
</div>
<div
className="rounded-xl p-4 border"
className="p-4 border rounded-xl"
style={{
background: sea.light,
borderColor: sea.light,
@@ -754,7 +754,7 @@ export default function DashboardPage() {
</div>
</div>
<div
className="rounded-xl p-4 border"
className="p-4 border rounded-xl"
style={{
background: sea.light,
borderColor: sea.light,
@@ -771,7 +771,7 @@ export default function DashboardPage() {
</div>
</CardContent>
</Card>
<Card className="rounded-2xl border-0 lg:col-span-2">
<Card className="border-0 rounded-2xl lg:col-span-2">
<CardContent className="p-5 space-y-3">
<div className="flex items-center justify-between">
<div
@@ -784,7 +784,7 @@ export default function DashboardPage() {
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<Server className="h-4 w-4" /> Nginx Reverse Proxy{" "}
<Server className="w-4 h-4" /> Nginx Reverse Proxy{" "}
<span
className="ml-auto font-medium"
style={{ color: sea.dark }}
@@ -793,7 +793,7 @@ export default function DashboardPage() {
</span>
</div>
<div className="flex items-center gap-2">
<Database className="h-4 w-4" /> MariaDB 10.11{" "}
<Database className="w-4 h-4" /> MariaDB 10.11{" "}
<span
className="ml-auto font-medium"
style={{ color: sea.dark }}
@@ -802,7 +802,7 @@ export default function DashboardPage() {
</span>
</div>
<div className="flex items-center gap-2">
<Workflow className="h-4 w-4" /> n8n (Postgres){" "}
<Workflow className="w-4 h-4" /> n8n (Postgres){" "}
<span
className="ml-auto font-medium"
style={{ color: sea.dark }}
@@ -811,7 +811,7 @@ export default function DashboardPage() {
</span>
</div>
<div className="flex items-center gap-2">
<Shield className="h-4 w-4" /> RBAC Enforcement{" "}
<Shield className="w-4 h-4" /> RBAC Enforcement{" "}
<span
className="ml-auto font-medium"
style={{ color: sea.dark }}
@@ -835,7 +835,7 @@ export default function DashboardPage() {
</CardContent>
</Card>
</div>
<Card className="rounded-2xl border-0">
<Card className="border-0 rounded-2xl">
<CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<div
@@ -850,11 +850,11 @@ export default function DashboardPage() {
<Tag>Viewer</Tag>
</div>
</div>
<div className="grid md:grid-cols-2 xl:grid-cols-4 gap-3">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{recent.map((r) => (
<div
key={r.code}
className="rounded-2xl p-4 border hover:shadow-sm transition"
className="p-4 transition border rounded-2xl hover:shadow-sm"
style={{
background: "white",
borderColor: "#efefef",
@@ -864,12 +864,12 @@ export default function DashboardPage() {
{r.type} {r.code}
</div>
<div
className="font-medium mt-1"
className="mt-1 font-medium"
style={{ color: sea.textDark }}
>
{r.title}
</div>
<div className="text-xs mt-2 opacity-70">{r.who}</div>
<div className="mt-2 text-xs opacity-70">{r.who}</div>
<div className="text-xs opacity-70">{r.when}</div>
</div>
))}
@@ -878,11 +878,11 @@ export default function DashboardPage() {
</Card>
</TabsContent>
<TabsContent value="reports" className="mt-4">
<div className="grid lg:grid-cols-2 gap-4">
<Card className="rounded-2xl border-0">
<div className="grid gap-4 lg:grid-cols-2">
<Card className="border-0 rounded-2xl">
<CardContent className="p-5">
<div
className="font-semibold mb-2"
className="mb-2 font-semibold"
style={{ color: sea.textDark }}
>
Report A: RFA Drawings Revisions
@@ -897,10 +897,10 @@ export default function DashboardPage() {
</div>
</CardContent>
</Card>
<Card className="rounded-2xl border-0">
<Card className="border-0 rounded-2xl">
<CardContent className="p-5">
<div
className="font-semibold mb-2"
className="mb-2 font-semibold"
style={{ color: sea.textDark }}
>
Report B: ไทมไลน RFA vs Drawing Rev
@@ -919,7 +919,7 @@ export default function DashboardPage() {
</TabsContent>
</Tabs>
<div className="text-xs opacity-70 text-center py-6">
<div className="py-6 text-xs text-center opacity-70">
Sea-themed Dashboard Sidebar อนได RBAC แสดง/อน Faceted
search KPI click-through Preview drawer Column
visibility/Density
@@ -942,7 +942,7 @@ export default function DashboardPage() {
<div>
<span className="opacity-70">แนบไฟล:</span> 2 รายการ (PDF, DWG)
</div>
<div className="pt-2 flex gap-2">
<div className="flex gap-2 pt-2">
{can(user, "rfa:create") && (
<Button className="btn-sea rounded-xl">แกไข</Button>
)}

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,6 @@
/* ====== shadcn/ui theme (light + dark) ====== */
:root {
--background: 210 40% 98%;
--foreground: 220 15% 15%;
/* โทน “น้ำทะเล” ตามธีมของคุณ */
--primary: 199 90% 40%;
@@ -78,9 +76,71 @@
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;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

22
frontend/components.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": false,
"tailwind": {
"config": "tailwind.config.js",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -0,0 +1,47 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props} />
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props} />
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props} />
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -1,3 +1,34 @@
export function Badge({ className="", ...props }) {
return <span className={`inline-flex items-center ${className}`} {...props} />;
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
...props
}) {
return (<div className={cn(badgeVariants({ variant }), className)} {...props} />);
}
export { Badge, badgeVariants }

View File

@@ -1,3 +1,48 @@
export function Button({ className = "", variant, size, ...props }) {
return <button className={`px-3 py-2 rounded-xl ${className}`} {...props} />;
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props} />
);
})
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -1,2 +1,50 @@
export function Card({ className="", ...props }) { return <div className={`bg-white shadow ${className}`} {...props} />; }
export function CardContent({ className="", ...props }) { return <div className={`p-4 ${className}`} {...props} />; }
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
{...props} />
))
Card.displayName = "Card"
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} />
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props} />
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props} />
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -1,12 +1,158 @@
import React from "react";
export function DropdownMenu({ children }){ return <div className="relative inline-block">{children}</div>; }
export function DropdownMenuTrigger({ asChild, children }){ return children; }
export function DropdownMenuContent({ align="end", className="", children }){
return <div className={`absolute mt-2 right-0 bg-white shadow rounded-xl p-2 z-50 ${className}`}>{children}</div>;
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props} />
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props} />
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props} />
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props} />
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} />
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props} />
);
}
export function DropdownMenuItem({ asChild, children, onClick }){
const C = asChild ? 'span' : 'button';
return <C onClick={onClick} className="block text-left w-full px-2 py-1.5 rounded hover:bg-gray-50">{children}</C>;
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
export function DropdownMenuSeparator(){ return <div className="my-1 border-t"/>; }
export function DropdownMenuLabel({ children }){ return <div className="px-2 py-1 text-xs opacity-70">{children}</div>; }

View File

@@ -1 +1,19 @@
export function Input({ className="", ...props }) { return <input className={`border rounded-xl p-2 w-full ${className}`} {...props} />; }
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props} />
);
})
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,18 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -1,5 +1,23 @@
export function Progress({ value=0 }) {
return (
<div className="h-2 bg-gray-200 rounded-full"><div className="h-2 bg-gray-500 rounded-full" style={{ width: `${value}%` }} /></div>
);
}
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} />
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -1,8 +1,24 @@
export function Switch({ checked=false, onCheckedChange }){
return (
<label className="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={checked} onChange={e=>onCheckedChange?.(e.target.checked)} />
<span>{checked?"On":"Off"}</span>
</label>
);
}
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)} />
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -1,17 +1,43 @@
import React from "react";
const Ctx = React.createContext();
export function Tabs({ defaultValue, children }){
const [val, setVal] = React.useState(defaultValue);
return <Ctx.Provider value={{val,setVal}}>{children}</Ctx.Provider>;
}
export function TabsList({ className="", ...props }){ return <div className={`inline-flex gap-2 ${className}`} {...props} /> }
export function TabsTrigger({ value, children, className="" }){
const { val, setVal } = React.useContext(Ctx);
const active = val===value;
return <button className={`px-3 py-1.5 rounded-xl border ${active?"bg-white":"bg-white/60"} ${className}`} onClick={()=>setVal(value)}>{children}</button>;
}
export function TabsContent({ value, children, className="" }){
const { val } = React.useContext(Ctx);
if (val!==value) return null;
return <div className={className}>{children}</div>;
}
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props} />
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props} />
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props} />
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -1,5 +1,28 @@
import React from "react";
export function TooltipProvider({ children }){ return children; }
export function Tooltip({ children }){ return <span className="relative">{children}</span>; }
export function TooltipTrigger({ asChild, children }){ return children; }
export function TooltipContent({ children }){ return <span className="ml-2 text-xs bg-black text-white px-2 py-1 rounded">{children}</span>; }
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props} />
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

7380
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "dms-frontend",
"version": "1.0.0",
"version": "0.7.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
@@ -10,24 +10,25 @@
"format": "prettier --write ."
},
"dependencies": {
"next": "15.0.3",
"react": "18.3.1",
"react-dom": "18.3.1",
"tailwindcss": "3.4.14",
"tailwindcss-animate": "1.0.7",
"postcss": "8.4.47",
"@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.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"tailwind-merge": "^2.2.1",
"framer-motion": "^11.2.10",
"lucide-react": "^0.451.0",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-tooltip": "^1.1.7",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-slot": "^1.1.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",

View File

@@ -10,65 +10,82 @@ module.exports = {
theme: {
container: {
center: true,
padding: "2rem",
screens: { "2xl": "1400px" },
padding: '2rem',
screens: {
'2xl': '1400px'
}
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
}
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
'accordion-down': {
from: {
height: '0'
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
to: {
height: 'var(--radix-accordion-content-height)'
}
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)'
},
to: {
height: '0'
}
}
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out'
}
}
},
plugins: [require("tailwindcss-animate")],
};

26
generate-shadcn-components.yml Executable file
View File

@@ -0,0 +1,26 @@
services:
setup-shadcn:
image: node:24-alpine
working_dir: /app
volumes:
- /share/Container/dms/frontend:/app
user: "${UID:-1000}:${GID:-1000}"
command: >
sh -c "
echo '📦 Installing dependencies...' &&
npm install &&
echo '🎨 Initializing shadcn/ui...' &&
npx shadcn@latest init -y -d &&
echo '📥 Adding components...' &&
npx shadcn@latest add -y button label input card badge tabs progress dropdown-menu tooltip switch &&
echo '✅ Done! Check components/ui/ directory'
"
# วิธีใช้:
# cd /share/Container/dms
# UID=$(id -u) GID=$(id -g) docker compose -f generate-shadcn-components.yml run --rm setup-shadcn
#
# หลังจากนั้น commit ไฟล์เหล่านี้:
# - components/ui/*.tsx (หรือ .jsx)
# - lib/utils.ts (หรือ .js)
# - components.json

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,6 +1,6 @@
1
/var/lib/postgresql/data
1758359032
1759215497
5432
/var/run/postgresql
*

Binary file not shown.

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "app",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}