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/ /frontend/node_modules/
**/node_modules/ **/node_modules/
# lockfiles # lockfiles
/backend/package-lock.json # /backend/package-lock.json
/frontend/package-lock.json # /frontend/package-lock.json
**/package-lock.json # **/package-lock.json
# ===================================================== # =====================================================
# Next.js build output # Next.js build output
# ===================================================== # =====================================================

View File

@@ -1,4 +1,4 @@
[/dms] [/dms]
max_log = 361676 max_log = 491862
number = 5 number = 1
finish = 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,27 +5,30 @@ services:
# context: ./frontend # context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
target: dev target: dev
args:
- NEXT_PUBLIC_API_BASE=https://lcbp3.np-dms.work
- NODE_ENV=development
image: dms-frontend:dev image: dms-frontend:dev
command: ["true"] command: ["true"]
frontend_prod_image: frontend_prod_image:
build: build:
context: /share/Container/dms/frontend context: /share/Container/dms/frontend
# context: ./frontend # context: ./frontend
args: args:
- NEXT_PUBLIC_API_BASE=https://lcbp3.np-dms.work - NEXT_PUBLIC_API_BASE=https://lcbp3.np-dms.work
- NODE_ENV=production #added - NODE_ENV=production
dockerfile: Dockerfile dockerfile: Dockerfile
target: prod target: prod
# environment:
# - NEXT_PUBLIC_API_BASE=https://lcbp3.np-dms.work
image: dms-frontend:prod image: dms-frontend:prod
command: ["true"] 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 # 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 # สำหรับ build บน local
# cd frontend # cd frontend
# docker build -t dms-frontend:dev --target dev . # docker build -t dms-frontend:dev --target dev .

View File

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

196
frontend/Dockerfile Executable file → Normal file
View File

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

452
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"; "use client";
import { useMemo, useState } from "react"; // ✅ ปรับให้ตรง backend: ใช้ Bearer token (ไม่ใช้ cookie)
import { useRouter, useSearchParams } from "next/navigation"; // - เรียก 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 { import {
Card, Card,
CardHeader, CardHeader,
@@ -15,327 +23,199 @@ import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";
const IS_DEV = process.env.NODE_ENV !== "production"; const API_BASE = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") || "";
// 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);
}
}
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const search = useSearchParams(); const searchParams = useSearchParams();
const redirectTo = search.get("from") || "/dashboard"; const nextPath = useMemo(
() => searchParams.get("next") || "/dashboard",
[searchParams]
);
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPw, setShowPw] = useState(false);
const [remember, setRemember] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(""); const [err, setErr] = 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]);
async function onSubmit(e) { async function onSubmit(e) {
e.preventDefault(); e.preventDefault();
setSubmitting(true); setErr("");
setError("");
if (IS_DEV) { if (!username.trim() || !password) {
setDebugInfo(null); setErr("กรอกชื่อผู้ใช้และรหัสผ่านให้ครบ");
setCopyState({ copied: false, error: "" }); return;
} }
try { try {
const res = await fetch(loginUrl, { setSubmitting(true);
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
cache: "no-store",
}); });
const data = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
const body = await parseBody(res); // รองรับข้อความ error จาก backend เช่น INVALID_CREDENTIALS
setErr(
const apiErr = { data?.error === "INVALID_CREDENTIALS"
name: "ApiError", ? "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง"
status: res.status, : data?.error || "เข้าสู่ระบบไม่สำเร็จ"
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)"
); );
if (IS_DEV) { return;
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) },
});
}
} }
// ✅ เก็บ 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 { } finally {
setSubmitting(false); 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 ( 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">
<CardHeader className="space-y-1"> <Card className="w-full max-w-md border-0 shadow-xl ring-1 ring-black/5 bg-white/90 backdrop-blur">
<CardTitle className="text-2xl text-sky-800">Sign in</CardTitle> <CardHeader className="space-y-1">
<CardDescription> <CardTitle className="text-2xl font-bold text-sky-800">
Enter your credentials to access the DMS เขาสระบบ
</CardDescription> </CardTitle>
</CardHeader> <CardDescription className="text-sky-700">
Document Management System LCBP3
</CardDescription>
</CardHeader>
<CardContent className="space-y-4"> <CardContent>
{error ? ( {err ? (
<Alert variant="destructive"> <Alert className="mb-4">
<AlertDescription>{error}</AlertDescription> <AlertDescription>{err}</AlertDescription>
</Alert> </Alert>
) : null} ) : null}
<form onSubmit={onSubmit} className="space-y-4"> <form onSubmit={onSubmit} className="grid gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="username">Username</Label> <Label htmlFor="username">อผใช</Label>
<Input <Input
id="username" id="username"
autoComplete="username" autoFocus
placeholder="superadmin" autoComplete="username"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
required placeholder="เช่น superadmin"
/> disabled={submitting}
</div> />
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="password">Password</Label> <Label htmlFor="password">รหสผาน</Label>
<Input <div className="relative">
id="password" <Input
type="password" id="password"
autoComplete="current-password" type={showPw ? "text" : "password"}
placeholder="••••••••" autoComplete="current-password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required placeholder="••••••••"
/> disabled={submitting}
</div> className="pr-10"
/>
<Button type="submit" className="w-full" disabled={submitting}> <button
{submitting ? "Signing in..." : "Sign in"}
</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" type="button"
size="sm" onClick={() => setShowPw((v) => !v)}
variant="secondary" className="absolute inset-y-0 px-2 my-auto text-xs bg-white border rounded-md right-2 hover:bg-slate-50"
onClick={handleCopyDebug} aria-label={showPw ? "ซ่อนรหัสผ่าน" : "แสดงรหัสผ่าน"}
disabled={!debugInfo} disabled={submitting}
aria-label="Copy debug info"
> >
{copyState.copied ? "Copied!" : "Copy debug"} {showPw ? "Hide" : "Show"}
</Button> </button>
</div> </div>
</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 className="flex items-center justify-between pt-1">
<div> <label className="inline-flex items-center gap-2 text-sm text-slate-600">
<span className="font-medium">Method:</span>{" "} <input
<code>{debugInfo.request.method}</code> type="checkbox"
</div> className="size-4 accent-sky-700"
)} checked={remember}
onChange={(e) => setRemember(e.target.checked)}
disabled={submitting}
/>
จดจำฉนไวในเครองน
</label>
{debugInfo?.response && ( <a
<> href="/forgot-password"
<div> className="text-sm text-sky-700 hover:text-sky-900 hover:underline"
<span className="font-medium">Status:</span>{" "} >
<code> มรหสผาน?
{debugInfo.response.status}{" "} </a>
{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>
</div>
)}
</CardContent>
<CardFooter className="justify-center text-xs text-gray-500"> <Button
&copy; {new Date().getFullYear()} np-dms.work type="submit"
</CardFooter> disabled={submitting}
</Card> className="mt-2 bg-sky-700 hover:bg-sky-800"
>
{submitting ? (
<span className="inline-flex items-center gap-2">
<Spinner /> กำลงเขาสระบบ
</span>
) : (
"เข้าสู่ระบบ"
)}
</Button>
</form>
</CardContent>
<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"; "use client";
@@ -9,7 +9,7 @@
// - เอา RootLayout/metadata ออก เพราะไฟล์เพจเป็น client component // - เอา RootLayout/metadata ออก เพราะไฟล์เพจเป็น client component
// - เพิ่มการอ่าน NEXT_PUBLIC_API_BASE และ error handling ให้ตรงกับ backend // - เพิ่มการอ่าน 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 { useSearchParams, useRouter } from "next/navigation";
import { import {
Card, Card,
@@ -26,7 +26,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") || ""; const API_BASE = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") || "";
export default function LoginPage() { function LoginForm() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const nextPath = useMemo( 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 แบบไม่พึ่งไลบรารีเสริม */ /** Spinner แบบไม่พึ่งไลบรารีเสริม */
function Spinner() { function Spinner() {
return ( return (
@@ -218,4 +251,4 @@ function Spinner() {
/> />
</svg> </svg>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,6 @@
/* ====== shadcn/ui theme (light + dark) ====== */ /* ====== shadcn/ui theme (light + dark) ====== */
:root { :root {
--background: 210 40% 98%;
--foreground: 220 15% 15%;
/* โทน “น้ำทะเล” ตามธีมของคุณ */ /* โทน “น้ำทะเล” ตามธีมของคุณ */
--primary: 199 90% 40%; --primary: 199 90% 40%;
@@ -77,6 +75,59 @@
} }
body { body {
@apply bg-background text-foreground antialiased; @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%;
} }
} }
@@ -84,3 +135,12 @@
.container { .container {
@apply mx-auto px-4; @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 }) { import * as React from "react"
return <span className={`inline-flex items-center ${className}`} {...props} />; 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 }) { import * as React from "react"
return <button className={`px-3 py-2 rounded-xl ${className}`} {...props} />; 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} />; } import * as React from "react"
export function CardContent({ className="", ...props }) { return <div className={`p-4 ${className}`} {...props} />; }
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"; "use client"
export function DropdownMenu({ children }){ return <div className="relative inline-block">{children}</div>; }
export function DropdownMenuTrigger({ asChild, children }){ return children; } import * as React from "react"
export function DropdownMenuContent({ align="end", className="", children }){ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
return <div className={`absolute mt-2 right-0 bg-white shadow rounded-xl p-2 z-50 ${className}`}>{children}</div>; import { Check, ChevronRight, Circle } from "lucide-react"
}
export function DropdownMenuItem({ asChild, children, onClick }){ import { cn } from "@/lib/utils"
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>; const DropdownMenu = DropdownMenuPrimitive.Root
}
export function DropdownMenuSeparator(){ return <div className="my-1 border-t"/>; } const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
export function DropdownMenuLabel({ children }){ return <div className="px-2 py-1 text-xs opacity-70">{children}</div>; }
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} />
);
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

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 }) { "use client"
return (
<div className="h-2 bg-gray-200 rounded-full"><div className="h-2 bg-gray-500 rounded-full" style={{ width: `${value}%` }} /></div> 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 }){ "use client"
return (
<label className="inline-flex items-center gap-2 cursor-pointer"> import * as React from "react"
<input type="checkbox" checked={checked} onChange={e=>onCheckedChange?.(e.target.checked)} /> import * as SwitchPrimitives from "@radix-ui/react-switch"
<span>{checked?"On":"Off"}</span>
</label> 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"; "use client"
const Ctx = React.createContext();
export function Tabs({ defaultValue, children }){ import * as React from "react"
const [val, setVal] = React.useState(defaultValue); import * as TabsPrimitive from "@radix-ui/react-tabs"
return <Ctx.Provider value={{val,setVal}}>{children}</Ctx.Provider>;
} import { cn } from "@/lib/utils"
export function TabsList({ className="", ...props }){ return <div className={`inline-flex gap-2 ${className}`} {...props} /> }
export function TabsTrigger({ value, children, className="" }){ const Tabs = TabsPrimitive.Root
const { val, setVal } = React.useContext(Ctx);
const active = val===value; const TabsList = React.forwardRef(({ className, ...props }, ref) => (
return <button className={`px-3 py-1.5 rounded-xl border ${active?"bg-white":"bg-white/60"} ${className}`} onClick={()=>setVal(value)}>{children}</button>; <TabsPrimitive.List
} ref={ref}
export function TabsContent({ value, children, className="" }){ className={cn(
const { val } = React.useContext(Ctx); "inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
if (val!==value) return null; className
return <div className={className}>{children}</div>; )}
} {...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"; "use client"
export function TooltipProvider({ children }){ return children; }
export function Tooltip({ children }){ return <span className="relative">{children}</span>; } import * as React from "react"
export function TooltipTrigger({ asChild, children }){ return children; } import * as TooltipPrimitive from "@radix-ui/react-tooltip"
export function TooltipContent({ children }){ return <span className="ml-2 text-xs bg-black text-white px-2 py-1 rounded">{children}</span>; }
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", "name": "dms-frontend",
"version": "1.0.0", "version": "0.7.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",
@@ -10,24 +10,25 @@
"format": "prettier --write ." "format": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"next": "15.0.3", "@radix-ui/react-dropdown-menu": "^2.1.16",
"react": "18.3.1", "@radix-ui/react-label": "^2.1.7",
"react-dom": "18.3.1", "@radix-ui/react-progress": "^1.1.7",
"tailwindcss": "3.4.14", "@radix-ui/react-slot": "^1.2.3",
"tailwindcss-animate": "1.0.7", "@radix-ui/react-switch": "^1.2.6",
"postcss": "8.4.47", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"autoprefixer": "10.4.20", "autoprefixer": "10.4.20",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"tailwind-merge": "^2.2.1",
"framer-motion": "^11.2.10", "framer-motion": "^11.2.10",
"lucide-react": "^0.451.0", "lucide-react": "^0.451.0",
"@radix-ui/react-dropdown-menu": "^2.1.6", "next": "15.0.3",
"@radix-ui/react-tooltip": "^1.1.7", "postcss": "8.4.47",
"@radix-ui/react-tabs": "^1.1.3", "react": "18.3.1",
"@radix-ui/react-switch": "^1.1.2", "react-dom": "18.3.1",
"@radix-ui/react-label": "^2.1.2", "tailwind-merge": "^2.6.0",
"@radix-ui/react-slot": "^1.1.0" "tailwindcss": "3.4.14",
"tailwindcss-animate": "^1.0.7"
}, },
"devDependencies": { "devDependencies": {
"eslint": "9.13.0", "eslint": "9.13.0",

View File

@@ -8,67 +8,84 @@ module.exports = {
"./src/**/*.{js,jsx,ts,tsx,mdx}", // เผื่อคุณเก็บ component ใน src "./src/**/*.{js,jsx,ts,tsx,mdx}", // เผื่อคุณเก็บ component ใน src
], ],
theme: { theme: {
container: { container: {
center: true, center: true,
padding: "2rem", padding: '2rem',
screens: { "2xl": "1400px" }, screens: {
}, '2xl': '1400px'
extend: { }
colors: { },
border: "hsl(var(--border))", extend: {
input: "hsl(var(--input))", colors: {
ring: "hsl(var(--ring))", border: 'hsl(var(--border))',
background: "hsl(var(--background))", input: 'hsl(var(--input))',
foreground: "hsl(var(--foreground))", ring: 'hsl(var(--ring))',
primary: { background: 'hsl(var(--background))',
DEFAULT: "hsl(var(--primary))", foreground: 'hsl(var(--foreground))',
foreground: "hsl(var(--primary-foreground))", primary: {
}, DEFAULT: 'hsl(var(--primary))',
secondary: { foreground: 'hsl(var(--primary-foreground))'
DEFAULT: "hsl(var(--secondary))", },
foreground: "hsl(var(--secondary-foreground))", secondary: {
}, DEFAULT: 'hsl(var(--secondary))',
destructive: { foreground: 'hsl(var(--secondary-foreground))'
DEFAULT: "hsl(var(--destructive))", },
foreground: "hsl(var(--destructive-foreground))", destructive: {
}, DEFAULT: 'hsl(var(--destructive))',
muted: { foreground: 'hsl(var(--destructive-foreground))'
DEFAULT: "hsl(var(--muted))", },
foreground: "hsl(var(--muted-foreground))", muted: {
}, DEFAULT: 'hsl(var(--muted))',
accent: { foreground: 'hsl(var(--muted-foreground))'
DEFAULT: "hsl(var(--accent))", },
foreground: "hsl(var(--accent-foreground))", accent: {
}, DEFAULT: 'hsl(var(--accent))',
popover: { foreground: 'hsl(var(--accent-foreground))'
DEFAULT: "hsl(var(--popover))", },
foreground: "hsl(var(--popover-foreground))", popover: {
}, DEFAULT: 'hsl(var(--popover))',
card: { foreground: 'hsl(var(--popover-foreground))'
DEFAULT: "hsl(var(--card))", },
foreground: "hsl(var(--card-foreground))", card: {
}, DEFAULT: 'hsl(var(--card))',
}, foreground: 'hsl(var(--card-foreground))'
borderRadius: { },
lg: "var(--radius)", chart: {
md: "calc(var(--radius) - 2px)", '1': 'hsl(var(--chart-1))',
sm: "calc(var(--radius) - 4px)", '2': 'hsl(var(--chart-2))',
}, '3': 'hsl(var(--chart-3))',
keyframes: { '4': 'hsl(var(--chart-4))',
"accordion-down": { '5': 'hsl(var(--chart-5))'
from: { height: "0" }, }
to: { height: "var(--radix-accordion-content-height)" }, },
}, borderRadius: {
"accordion-up": { lg: 'var(--radius)',
from: { height: "var(--radix-accordion-content-height)" }, md: 'calc(var(--radius) - 2px)',
to: { height: "0" }, sm: 'calc(var(--radius) - 4px)'
}, },
}, keyframes: {
animation: { 'accordion-down': {
"accordion-down": "accordion-down 0.2s ease-out", from: {
"accordion-up": "accordion-up 0.2s ease-out", 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'
}
}
}, },
plugins: [require("tailwindcss-animate")], 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 1
/var/lib/postgresql/data /var/lib/postgresql/data
1758359032 1759215497
5432 5432
/var/run/postgresql /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": {}
}