build frontend ใหม่ ผ่านทั้ง dev และ proc
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -14,9 +14,9 @@ Documents/
|
||||
/frontend/node_modules/
|
||||
**/node_modules/
|
||||
# lockfiles
|
||||
/backend/package-lock.json
|
||||
/frontend/package-lock.json
|
||||
**/package-lock.json
|
||||
# /backend/package-lock.json
|
||||
# /frontend/package-lock.json
|
||||
# **/package-lock.json
|
||||
# =====================================================
|
||||
# Next.js build output
|
||||
# =====================================================
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[/dms]
|
||||
max_log = 361676
|
||||
number = 5
|
||||
max_log = 491862
|
||||
number = 1
|
||||
finish = 1
|
||||
|
||||
13033
.qsync/meta/qmeta0
13033
.qsync/meta/qmeta0
File diff suppressed because it is too large
Load Diff
11170
.qsync/meta/qmeta1
11170
.qsync/meta/qmeta1
File diff suppressed because it is too large
Load Diff
10816
.qsync/meta/qmeta2
10816
.qsync/meta/qmeta2
File diff suppressed because it is too large
Load Diff
10997
.qsync/meta/qmeta3
10997
.qsync/meta/qmeta3
File diff suppressed because it is too large
Load Diff
1955
.qsync/meta/qmeta4
1955
.qsync/meta/qmeta4
File diff suppressed because it is too large
Load Diff
0
[frontend_prod_image
Normal file
0
[frontend_prod_image
Normal file
@@ -5,6 +5,9 @@ services:
|
||||
# context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
target: dev
|
||||
args:
|
||||
- NEXT_PUBLIC_API_BASE=https://lcbp3.np-dms.work
|
||||
- NODE_ENV=development
|
||||
image: dms-frontend:dev
|
||||
command: ["true"]
|
||||
|
||||
@@ -14,18 +17,18 @@ services:
|
||||
# context: ./frontend
|
||||
args:
|
||||
- NEXT_PUBLIC_API_BASE=https://lcbp3.np-dms.work
|
||||
- NODE_ENV=production #added
|
||||
- NODE_ENV=production
|
||||
dockerfile: Dockerfile
|
||||
target: prod
|
||||
# environment:
|
||||
# - NEXT_PUBLIC_API_BASE=https://lcbp3.np-dms.work
|
||||
image: dms-frontend:prod
|
||||
command: ["true"]
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_BASE=https://lcbp3.np-dms.work
|
||||
- NODE_ENV=production
|
||||
|
||||
# docker compose -f docker-frontend-build.yml build --no-cache
|
||||
# **** สำหรับ build บน server เอา ## ออก *****
|
||||
|
||||
# สร้าง package-lock.json
|
||||
# cd frontend
|
||||
# docker run --rm -v "$PWD:/app" -w /app node:24-alpine npm install
|
||||
|
||||
# สำหรับ build บน local
|
||||
# cd frontend
|
||||
# docker build -t dms-frontend:dev --target dev .
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.next
|
||||
.next/cache
|
||||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
logs
|
||||
.env*.local
|
||||
*.logs
|
||||
102
frontend/Dockerfile
Executable file → Normal file
102
frontend/Dockerfile
Executable file → Normal file
@@ -1,11 +1,12 @@
|
||||
# syntax=docker/dockerfile:1.6
|
||||
|
||||
############ Base ############
|
||||
FROM node:20-alpine AS base
|
||||
FROM node:24-alpine AS base
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache bash curl tzdata \
|
||||
&& ln -snf /usr/share/zoneinfo/Asia/Bangkok /etc/localtime \
|
||||
&& echo "Asia/Bangkok" > /etc/timezone
|
||||
|
||||
ARG NEXT_PUBLIC_API_BASE
|
||||
ENV TZ=Asia/Bangkok \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
@@ -13,62 +14,109 @@ ENV TZ=Asia/Bangkok \
|
||||
WATCHPACK_POLLING=true \
|
||||
NEXT_PUBLIC_API_BASE=${NEXT_PUBLIC_API_BASE} \
|
||||
npm_config_yes=true
|
||||
# (ค่าพวกนี้ซ้ำกับ compose ได้ ไม่เป็นปัญหา)
|
||||
|
||||
# สร้างโฟลเดอร์ที่ Next.js ต้องเขียนทับ และกำหนดสิทธิ์
|
||||
RUN mkdir -p /app/.next/cache /app/.next/server /app/.next/types && \
|
||||
chmod -R 777 /app/.next
|
||||
|
||||
############ Deps (install) ############
|
||||
FROM base AS deps
|
||||
## COPY package.json package-lock.json* ./
|
||||
COPY package.json package-lock.json* ./
|
||||
# ถ้ามี lock ใช้ npm ci; ถ้าไม่มีก็ npm i
|
||||
## RUN if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; else npm install --no-audit --no-fund; fi
|
||||
|
||||
COPY package.json ./
|
||||
# ไม่ copy lock เพื่อบังคับให้ใช้ npm install
|
||||
RUN npm install --no-audit --no-fund
|
||||
|
||||
# เพิ่ม shadcn/ui + tailwind deps
|
||||
# RUN npm install -D tailwindcss postcss autoprefixer shadcn@latest \
|
||||
# && npm install class-variance-authority clsx framer-motion lucide-react tailwind-merge tailwindcss-animate
|
||||
|
||||
# init tailwind config (กัน No Tailwind CSS configuration found)
|
||||
# RUN npx tailwindcss init -p
|
||||
|
||||
# bake components ของ shadcn แบบ non-interactive
|
||||
# RUN npx shadcn add -y button badge card input tabs progress dropdown-menu tooltip switch
|
||||
RUN if [ -f package-lock.json ]; then \
|
||||
npm ci --no-audit --no-fund; \
|
||||
else \
|
||||
npm install --no-audit --no-fund; \
|
||||
fi
|
||||
|
||||
############ Dev (hot-reload) ############
|
||||
# ใช้ร่วมกับ compose: bind ทั้งโปรเจ็กต์ → /app
|
||||
# และใช้ named volume แยกสำหรับ /app/node_modules และ /app/.next
|
||||
FROM base AS dev
|
||||
RUN apk add --no-cache git openssh-client ca-certificates
|
||||
# สำคัญ: สร้างโฟลเดอร์ที่ Next ใช้งาน (เวลายังไม่ bind อะไรเข้ามา)
|
||||
# สร้างโฟลเดอร์ที่ Next ใช้งาน
|
||||
RUN install -d -o node -g node /app/public /app/app /app/.logs /app/.next /app/.next/cache
|
||||
# นำ node_modules จากชั้น deps มาไว้ (ลดเวลาตอน start ครั้งแรก)
|
||||
# นำ node_modules จากชั้น deps มาไว้
|
||||
COPY --from=deps /app/node_modules /app/node_modules
|
||||
|
||||
# ไม่กำหนด USER ที่นี่ ปล่อยให้ compose คุม (ตอนนี้คุณใช้ user: "1000:1000")
|
||||
# สำหรับ development: คาดหวังว่าจะมี bind mount source code
|
||||
# แต่ก็ COPY ไว้เผื่อรัน standalone
|
||||
COPY --chown=node:node . .
|
||||
|
||||
ENV NODE_ENV=development
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "dev"]
|
||||
|
||||
############ Build (production) ############
|
||||
FROM deps AS builder
|
||||
# ล้างและสร้างโฟลเดอร์ใหม่ทั้งหมด
|
||||
RUN rm -rf /app/.next && \
|
||||
mkdir -p /app/.next && \
|
||||
chmod 777 /app/.next
|
||||
|
||||
# Copy all necessary files for build
|
||||
COPY . .
|
||||
|
||||
ARG NEXT_PUBLIC_API_BASE
|
||||
ENV NEXT_PUBLIC_API_BASE=${NEXT_PUBLIC_API_BASE}
|
||||
|
||||
# Debug: Check if components exist before build
|
||||
RUN echo "=== Checking components ===" && \
|
||||
ls -la components/ui/ || echo "WARNING: No components/ui" && \
|
||||
ls -la lib/utils.* || echo "WARNING: No lib/utils" && \
|
||||
cat jsconfig.json || cat tsconfig.json || echo "WARNING: No jsconfig/tsconfig" && \
|
||||
echo "=== Checking .next permissions ===" && \
|
||||
ls -lad /app/.next
|
||||
|
||||
RUN npm run build
|
||||
|
||||
############ Prod runtime (optimized) ############
|
||||
FROM node:20-alpine AS prod
|
||||
FROM node:24-alpine AS prod
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# RUN apk add --no-cache libc6-compat \
|
||||
# && addgroup -g 1000 node && adduser -D -u 1000 -G node node
|
||||
|
||||
# คัดเฉพาะของจำเป็น
|
||||
COPY --from=builder /app/package.json /app/package-lock.json* ./
|
||||
# ติดตั้งเฉพาะ prod deps
|
||||
RUN if [ -f package-lock.json ]; then npm ci --omit=dev --no-audit --no-fund; else npm i --omit=dev --no-audit --no-fund; fi
|
||||
RUN if [ -f package-lock.json ]; then \
|
||||
npm ci --omit=dev --no-audit --no-fund; \
|
||||
else \
|
||||
npm i --omit=dev --no-audit --no-fund; \
|
||||
fi
|
||||
|
||||
COPY --from=builder --chown=node:node /app/.next ./.next
|
||||
COPY --from=builder --chown=node:node /app/public ./public
|
||||
COPY --from=builder --chown=node:node /app/next.config.* ./
|
||||
|
||||
RUN mkdir -p /app/.next/cache && chown -R node:node /app
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "start"]
|
||||
|
||||
# === STEP 1: Generate Components ===
|
||||
# cd /share/Container/dms
|
||||
# UID=$(id -u) GID=$(id -g) docker compose -f generate-shadcn-components.yml run --rm setup-shadcn
|
||||
|
||||
# === STEP 2: ตรวจสอบไฟล์ ===
|
||||
# cd frontend
|
||||
# echo "📁 Components created:"
|
||||
# ls -la components/ui/ 2>/dev/null || echo "⚠️ No components/ui/ directory"
|
||||
# ls -la lib/ 2>/dev/null || echo "⚠️ No lib/ directory"
|
||||
# ls -la components.json 2>/dev/null || echo "⚠️ No components.json"
|
||||
|
||||
# === STEP 3: Commit to Git ===
|
||||
# git add components/ lib/ components.json
|
||||
# git status
|
||||
# git commit -m "Add shadcn/ui components and utilities"
|
||||
|
||||
# === STEP 4: Build Docker Image ===
|
||||
# cd /share/Container/dms
|
||||
# docker compose -f docker-frontend-build.yml build --no-cache
|
||||
# docker compose -f docker-frontend-build.yml build --no-cache 2>&1 | tee build.log
|
||||
|
||||
# === ต่อไปที่ต้องเพิ่ม component ใหม่:
|
||||
# docker run --rm -v "$PWD:/app" -w /app node:24-alpine \
|
||||
# npx shadcn@latest add dialog
|
||||
|
||||
# แล้ว commit
|
||||
# git add components/ui/dialog.jsx
|
||||
# git commit -m "Add dialog component"
|
||||
@@ -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
452
frontend/app/(auth)/login/page copy.jsx
Normal file → Executable file
@@ -1,8 +1,16 @@
|
||||
// frontend/app/(auth)/login/page.jsx
|
||||
// File: frontend/app/(auth)/login/page
|
||||
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
// ✅ ปรับให้ตรง backend: ใช้ Bearer token (ไม่ใช้ cookie)
|
||||
// - เรียก POST /api/auth/login → รับ { token, refresh_token, user }
|
||||
// - เก็บ token/refresh_token ใน localStorage (หรือ sessionStorage ถ้าไม่ติ๊กจำไว้)
|
||||
// - ไม่ใช้ credentials: "include" อีกต่อไป
|
||||
// - เอา RootLayout/metadata ออก เพราะไฟล์เพจเป็น client component
|
||||
// - เพิ่มการอ่าน NEXT_PUBLIC_API_BASE และ error handling ให้ตรงกับ backend
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
@@ -15,327 +23,199 @@ import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
const IS_DEV = process.env.NODE_ENV !== "production";
|
||||
|
||||
// URL builder กันเคสซ้ำ /api
|
||||
function buildLoginUrl() {
|
||||
const base = (process.env.NEXT_PUBLIC_API_BASE || "").replace(/\/+$/, "");
|
||||
if (base.endsWith("/api")) return `${base}/auth/login`;
|
||||
return `${base}/api/auth/login`;
|
||||
}
|
||||
|
||||
// helper: parse response body เป็น json หรือ text
|
||||
async function parseBody(res) {
|
||||
const text = await res.text();
|
||||
try {
|
||||
return { raw: text, json: JSON.parse(text) };
|
||||
} catch {
|
||||
return { raw: text, json: null };
|
||||
}
|
||||
}
|
||||
|
||||
// สร้างข้อความ debug ที่พร้อม copy
|
||||
function stringifyDebug(debugInfo) {
|
||||
try {
|
||||
return JSON.stringify(debugInfo, null, 2);
|
||||
} catch {
|
||||
return String(debugInfo);
|
||||
}
|
||||
}
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") || "";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
const redirectTo = search.get("from") || "/dashboard";
|
||||
const searchParams = useSearchParams();
|
||||
const nextPath = useMemo(
|
||||
() => searchParams.get("next") || "/dashboard",
|
||||
[searchParams]
|
||||
);
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [remember, setRemember] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// สำหรับ debug panel
|
||||
const [debugInfo, setDebugInfo] = useState(null);
|
||||
const [copyState, setCopyState] = useState({ copied: false, error: "" });
|
||||
|
||||
const loginUrl = useMemo(buildLoginUrl, [process.env.NEXT_PUBLIC_API_BASE]);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
if (IS_DEV) {
|
||||
setDebugInfo(null);
|
||||
setCopyState({ copied: false, error: "" });
|
||||
setErr("");
|
||||
|
||||
if (!username.trim() || !password) {
|
||||
setErr("กรอกชื่อผู้ใช้และรหัสผ่านให้ครบ");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(loginUrl, {
|
||||
setSubmitting(true);
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ username, password }),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const body = await parseBody(res);
|
||||
|
||||
const apiErr = {
|
||||
name: "ApiError",
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
body: body.json ?? body.raw,
|
||||
message: (() => {
|
||||
const msgFromJson =
|
||||
(body.json && (body.json.error || body.json.message)) || null;
|
||||
|
||||
if (res.status === 400)
|
||||
return `Bad request: ${msgFromJson ?? res.statusText}`;
|
||||
if (res.status === 401)
|
||||
return `Unauthenticated: ${msgFromJson ?? "Invalid credentials"}`;
|
||||
if (res.status === 403)
|
||||
return `Forbidden: ${msgFromJson ?? res.statusText}`;
|
||||
if (res.status === 404)
|
||||
return `Not found: ${msgFromJson ?? res.statusText}`;
|
||||
if (res.status >= 500)
|
||||
return `Server error (${res.status}): ${
|
||||
msgFromJson ?? res.statusText
|
||||
}`;
|
||||
return `${res.status} ${res.statusText}: ${
|
||||
msgFromJson ?? "Request failed"
|
||||
}`;
|
||||
})(),
|
||||
};
|
||||
|
||||
if (IS_DEV) {
|
||||
setDebugInfo({
|
||||
kind: "api",
|
||||
request: {
|
||||
url: loginUrl,
|
||||
method: "POST",
|
||||
payload: { username: "(masked)", password: "(masked)" },
|
||||
},
|
||||
response: {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
body: apiErr.body,
|
||||
},
|
||||
env: {
|
||||
NEXT_PUBLIC_API_BASE:
|
||||
process.env.NEXT_PUBLIC_API_BASE || "(unset)",
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
throw apiErr;
|
||||
}
|
||||
|
||||
// ✅ สำเร็จ
|
||||
if (IS_DEV) {
|
||||
setDebugInfo({
|
||||
kind: "success",
|
||||
request: { url: loginUrl, method: "POST" },
|
||||
note: "Login success. Redirecting…",
|
||||
});
|
||||
}
|
||||
router.push(redirectTo);
|
||||
} catch (err) {
|
||||
if (err?.name === "ApiError") {
|
||||
setError(err.message);
|
||||
} else if (err instanceof TypeError && /fetch/i.test(err.message)) {
|
||||
setError(
|
||||
"Network error: ไม่สามารถเชื่อมต่อเซิร์ฟเวอร์ได้ (ตรวจสอบ proxy/NPM/SSL)"
|
||||
// รองรับข้อความ error จาก backend เช่น INVALID_CREDENTIALS
|
||||
setErr(
|
||||
data?.error === "INVALID_CREDENTIALS"
|
||||
? "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง"
|
||||
: data?.error || "เข้าสู่ระบบไม่สำเร็จ"
|
||||
);
|
||||
if (IS_DEV) {
|
||||
setDebugInfo({
|
||||
kind: "network",
|
||||
request: { url: loginUrl, method: "POST" },
|
||||
error: { message: err.message },
|
||||
hint: "เช็คว่า NPM ชี้ proxy /api ไปที่ backend ถูก network/port, และ TLS chain ถูกต้อง",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setError(err?.message || "Unexpected error");
|
||||
if (IS_DEV) {
|
||||
setDebugInfo({
|
||||
kind: "unknown",
|
||||
request: { url: loginUrl, method: "POST" },
|
||||
error: { message: String(err) },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ เก็บ token ตามโหมดจำไว้/ไม่จำ
|
||||
const storage = remember ? window.localStorage : window.sessionStorage;
|
||||
storage.setItem("dms.token", data.token);
|
||||
storage.setItem("dms.refresh_token", data.refresh_token);
|
||||
storage.setItem("dms.user", JSON.stringify(data.user || {}));
|
||||
|
||||
// (ออปชัน) เผยแพร่ event ให้แท็บอื่นทราบ
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", { key: "dms.auth", newValue: "login" })
|
||||
);
|
||||
} catch {}
|
||||
|
||||
router.replace(nextPath);
|
||||
} catch (e) {
|
||||
setErr("เชื่อมต่อเซิร์ฟเวอร์ไม่ได้ กรุณาลองใหม่");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyDebug() {
|
||||
if (!debugInfo) return;
|
||||
const text = stringifyDebug(debugInfo);
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
// Fallback
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.left = "-9999px";
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
setCopyState({ copied: true, error: "" });
|
||||
setTimeout(() => setCopyState({ copied: false, error: "" }), 1500);
|
||||
} catch (e) {
|
||||
setCopyState({
|
||||
copied: false,
|
||||
error: "คัดลอกไม่สำเร็จ (permission ของ clipboard?)",
|
||||
});
|
||||
setTimeout(() => setCopyState({ copied: false, error: "" }), 2500);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mx-auto w-full max-w-md shadow-lg">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl text-sky-800">Sign in</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your credentials to access the DMS
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<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 className="space-y-4">
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<CardContent>
|
||||
{err ? (
|
||||
<Alert className="mb-4">
|
||||
<AlertDescription>{err}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
autoComplete="username"
|
||||
placeholder="superadmin"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<form onSubmit={onSubmit} className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">ชื่อผู้ใช้</Label>
|
||||
<Input
|
||||
id="username"
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="เช่น superadmin"
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={submitting}>
|
||||
{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
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">รหัสผ่าน</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPw ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={submitting}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleCopyDebug}
|
||||
disabled={!debugInfo}
|
||||
aria-label="Copy debug info"
|
||||
onClick={() => setShowPw((v) => !v)}
|
||||
className="absolute inset-y-0 px-2 my-auto text-xs bg-white border rounded-md right-2 hover:bg-slate-50"
|
||||
aria-label={showPw ? "ซ่อนรหัสผ่าน" : "แสดงรหัสผ่าน"}
|
||||
disabled={submitting}
|
||||
>
|
||||
{copyState.copied ? "Copied!" : "Copy debug"}
|
||||
</Button>
|
||||
{showPw ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-2 text-xs text-sky-900">
|
||||
<div>
|
||||
<span className="font-medium">Request URL:</span>{" "}
|
||||
<code className="break-all">{loginUrl}</code>
|
||||
</div>
|
||||
|
||||
{debugInfo?.request?.method && (
|
||||
<div>
|
||||
<span className="font-medium">Method:</span>{" "}
|
||||
<code>{debugInfo.request.method}</code>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-slate-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 accent-sky-700"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
จดจำฉันไว้ในเครื่องนี้
|
||||
</label>
|
||||
|
||||
{debugInfo?.response && (
|
||||
<>
|
||||
<div>
|
||||
<span className="font-medium">Status:</span>{" "}
|
||||
<code>
|
||||
{debugInfo.response.status}{" "}
|
||||
{debugInfo.response.statusText}
|
||||
</code>
|
||||
</div>
|
||||
<div className="font-medium">Response body:</div>
|
||||
<pre className="max-h-48 overflow-auto rounded bg-white p-2">
|
||||
{typeof debugInfo.response.body === "string"
|
||||
? debugInfo.response.body
|
||||
: JSON.stringify(debugInfo.response.body, null, 2)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
|
||||
{debugInfo?.error && (
|
||||
<>
|
||||
<div className="font-medium">Error:</div>
|
||||
<pre className="max-h-48 overflow-auto rounded bg-white p-2">
|
||||
{JSON.stringify(debugInfo.error, null, 2)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
|
||||
{debugInfo?.env && (
|
||||
<>
|
||||
<div className="font-medium">Env:</div>
|
||||
<pre className="max-h-40 overflow-auto rounded bg-white p-2">
|
||||
{JSON.stringify(debugInfo.env, null, 2)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
|
||||
{debugInfo?.note && (
|
||||
<div className="italic text-sky-700">{debugInfo.note}</div>
|
||||
)}
|
||||
{debugInfo?.hint && (
|
||||
<div className="italic text-sky-700">
|
||||
Hint: {debugInfo.hint}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{copyState.error && (
|
||||
<div className="text-red-600">{copyState.error}</div>
|
||||
)}
|
||||
<a
|
||||
href="/forgot-password"
|
||||
className="text-sm text-sky-700 hover:text-sky-900 hover:underline"
|
||||
>
|
||||
ลืมรหัสผ่าน?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="justify-center text-xs text-gray-500">
|
||||
© {new Date().getFullYear()} np-dms.work
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="mt-2 bg-sky-700 hover:bg-sky-800"
|
||||
>
|
||||
{submitting ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Spinner /> กำลังเข้าสู่ระบบ…
|
||||
</span>
|
||||
) : (
|
||||
"เข้าสู่ระบบ"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="text-xs text-center text-slate-500">
|
||||
© {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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// File: frontend/app/(auth)/login/page· typescript
|
||||
// File: frontend/app/(auth)/login/page.jsx
|
||||
|
||||
"use client";
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
// - เอา RootLayout/metadata ออก เพราะไฟล์เพจเป็น client component
|
||||
// - เพิ่มการอ่าน NEXT_PUBLIC_API_BASE และ error handling ให้ตรงกับ backend
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, Suspense } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import {
|
||||
Card,
|
||||
@@ -26,7 +26,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") || "";
|
||||
|
||||
export default function LoginPage() {
|
||||
function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const nextPath = useMemo(
|
||||
@@ -194,6 +194,39 @@ export default function LoginPage() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoginPageSkeleton />}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
/** Loading skeleton */
|
||||
function LoginPageSkeleton() {
|
||||
return (
|
||||
<div className="grid min-h-[calc(100vh-4rem)] place-items-center p-4">
|
||||
<Card className="w-full max-w-md border-0 shadow-xl ring-1 ring-black/5 bg-white/90 backdrop-blur">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-sky-800">
|
||||
เข้าสู่ระบบ
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sky-700">
|
||||
Document Management System • LCBP3
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 animate-pulse">
|
||||
<div className="h-10 rounded bg-slate-200"></div>
|
||||
<div className="h-10 rounded bg-slate-200"></div>
|
||||
<div className="h-10 rounded bg-slate-200"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Spinner แบบไม่พึ่งไลบรารีเสริม */
|
||||
function Spinner() {
|
||||
return (
|
||||
|
||||
@@ -66,7 +66,7 @@ const sea = {
|
||||
const can = (user, perm) => new Set(user?.permissions || []).has(perm);
|
||||
const Tag = ({ children }) => (
|
||||
<Badge
|
||||
className="rounded-full px-3 py-1 text-xs border-0"
|
||||
className="px-3 py-1 text-xs border-0 rounded-full"
|
||||
style={{ background: sea.light, color: sea.dark }}
|
||||
>
|
||||
{children}
|
||||
@@ -79,8 +79,8 @@ const SidebarItem = ({ label, icon: Icon, active = false, badge }) => (
|
||||
}`}
|
||||
style={{ borderColor: "#ffffff40", color: sea.textDark }}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span className="grow font-medium">{label}</span>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span className="font-medium grow">{label}</span>
|
||||
{badge ? (
|
||||
<span
|
||||
className="text-xs rounded-full px-2 py-0.5"
|
||||
@@ -89,20 +89,20 @@ const SidebarItem = ({ label, icon: Icon, active = false, badge }) => (
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronRight className="h-4 w-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<ChevronRight className="w-4 h-4 transition-opacity opacity-0 group-hover:opacity-100" />
|
||||
</button>
|
||||
);
|
||||
const KPI = ({ label, value, icon: Icon, onClick }) => (
|
||||
<Card
|
||||
onClick={onClick}
|
||||
className="rounded-2xl shadow-sm border-0 cursor-pointer hover:shadow transition"
|
||||
className="transition border-0 shadow-sm cursor-pointer rounded-2xl hover:shadow"
|
||||
style={{ background: "white" }}
|
||||
>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<span className="text-sm opacity-70">{label}</span>
|
||||
<div className="rounded-xl p-2" style={{ background: sea.light }}>
|
||||
<Icon className="h-5 w-5" style={{ color: sea.dark }} />
|
||||
<div className="p-2 rounded-xl" style={{ background: sea.light }}>
|
||||
<Icon className="w-5 h-5" style={{ color: sea.dark }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-3xl font-bold" style={{ color: sea.textDark }}>
|
||||
@@ -124,7 +124,7 @@ function PreviewDrawer({ open, onClose, children }) {
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="font-medium">รายละเอียด</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-5 w-5" />
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-4 overflow-auto h-[calc(100%-56px)]">{children}</div>
|
||||
@@ -306,23 +306,23 @@ export default function DashboardPage() {
|
||||
}}
|
||||
>
|
||||
<header
|
||||
className="sticky top-0 z-40 backdrop-blur-md border-b"
|
||||
className="sticky top-0 z-40 border-b backdrop-blur-md"
|
||||
style={{
|
||||
borderColor: "#ffffff66",
|
||||
background: "rgba(230,247,251,0.7)",
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto max-w-7xl px-4 py-2 flex items-center gap-3">
|
||||
<div className="flex items-center gap-3 px-4 py-2 mx-auto max-w-7xl">
|
||||
<button
|
||||
className="h-9 w-9 rounded-2xl flex items-center justify-center shadow-sm"
|
||||
className="flex items-center justify-center shadow-sm h-9 w-9 rounded-2xl"
|
||||
style={{ background: sea.dark }}
|
||||
onClick={() => setSidebarOpen((v) => !v)}
|
||||
aria-label={sidebarOpen ? "ซ่อนแถบด้านข้าง" : "แสดงแถบด้านข้าง"}
|
||||
>
|
||||
{sidebarOpen ? (
|
||||
<PanelLeft className="h-5 w-5 text-white" />
|
||||
<PanelLeft className="w-5 h-5 text-white" />
|
||||
) : (
|
||||
<PanelRight className="h-5 w-5 text-white" />
|
||||
<PanelRight className="w-5 h-5 text-white" />
|
||||
)}
|
||||
</button>
|
||||
<div>
|
||||
@@ -338,35 +338,35 @@ export default function DashboardPage() {
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="ml-auto rounded-2xl btn-sea flex items-center gap-2">
|
||||
System <ChevronDown className="h-4 w-4" />
|
||||
<Button className="flex items-center gap-2 ml-auto rounded-2xl btn-sea">
|
||||
System <ChevronDown className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-56">
|
||||
<DropdownMenuLabel>ระบบ</DropdownMenuLabel>
|
||||
{can(user, "admin:view") && (
|
||||
<DropdownMenuItem>
|
||||
<Settings className="h-4 w-4 mr-2" /> Admin
|
||||
<Settings className="w-4 h-4 mr-2" /> Admin
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{can(user, "users:manage") && (
|
||||
<DropdownMenuItem>
|
||||
<Users className="h-4 w-4 mr-2" /> ผู้ใช้/บทบาท
|
||||
<Users className="w-4 h-4 mr-2" /> ผู้ใช้/บทบาท
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{can(user, "health:view") && (
|
||||
<DropdownMenuItem asChild>
|
||||
<a href="/health" className="flex items-center w-full">
|
||||
<Server className="h-4 w-4 mr-2" /> Health{" "}
|
||||
<ExternalLink className="h-3 w-3 ml-auto" />
|
||||
<Server className="w-4 h-4 mr-2" /> Health{" "}
|
||||
<ExternalLink className="w-3 h-3 ml-auto" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{can(user, "workflow:view") && (
|
||||
<DropdownMenuItem asChild>
|
||||
<a href="/workflow" className="flex items-center w-full">
|
||||
<Workflow className="h-4 w-4 mr-2" /> Workflow (n8n){" "}
|
||||
<ExternalLink className="h-3 w-3 ml-auto" />
|
||||
<Workflow className="w-4 h-4 mr-2" /> Workflow (n8n){" "}
|
||||
<ExternalLink className="w-3 h-3 ml-auto" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
@@ -375,8 +375,8 @@ export default function DashboardPage() {
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="rounded-2xl btn-sea ml-2">
|
||||
<Plus className="h-4 w-4 mr-1" /> New
|
||||
<Button className="ml-2 rounded-2xl btn-sea">
|
||||
<Plus className="w-4 h-4 mr-1" /> New
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
@@ -384,7 +384,7 @@ export default function DashboardPage() {
|
||||
can(user, perm) ? (
|
||||
<DropdownMenuItem key={label} asChild>
|
||||
<Link href={href} className="flex items-center">
|
||||
<Icon className="h-4 w-4 mr-2" />
|
||||
<Icon className="w-4 h-4 mr-2" />
|
||||
{label}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
@@ -392,7 +392,7 @@ export default function DashboardPage() {
|
||||
<Tooltip key={label}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="px-2 py-1.5 text-sm opacity-40 cursor-not-allowed flex items-center">
|
||||
<Icon className="h-4 w-4 mr-2" />
|
||||
<Icon className="w-4 h-4 mr-2" />
|
||||
{label}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
@@ -404,26 +404,26 @@ export default function DashboardPage() {
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<Layers className="h-4 w-4 mr-2" /> Import / Bulk upload
|
||||
<Layers className="w-4 h-4 mr-2" /> Import / Bulk upload
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mx-auto max-w-7xl px-4 py-6 grid grid-cols-12 gap-6">
|
||||
<div className="grid grid-cols-12 gap-6 px-4 py-6 mx-auto max-w-7xl">
|
||||
{sidebarOpen && (
|
||||
<aside className="col-span-12 lg:col-span-3 xl:col-span-3">
|
||||
<div
|
||||
className="rounded-3xl p-4 border"
|
||||
className="p-4 border rounded-3xl"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.7)",
|
||||
borderColor: "#ffffff66",
|
||||
}}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<ShieldCheck
|
||||
className="h-5 w-5"
|
||||
className="w-5 h-5"
|
||||
style={{ color: sea.dark }}
|
||||
/>
|
||||
<div className="text-sm">
|
||||
@@ -432,20 +432,20 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 opacity-70" />
|
||||
<Search className="absolute w-4 h-4 -translate-y-1/2 left-3 top-1/2 opacity-70" />
|
||||
<Input
|
||||
placeholder="ค้นหา RFA / Drawing / Transmittal / Code…"
|
||||
className="pl-9 rounded-2xl border-0 bg-white"
|
||||
className="bg-white border-0 pl-9 rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="rounded-2xl p-3 border mb-3"
|
||||
className="p-3 mb-3 border rounded-2xl"
|
||||
style={{ borderColor: "#eef6f8", background: "#ffffffaa" }}
|
||||
>
|
||||
<div className="text-xs font-medium mb-2">ตัวกรอง</div>
|
||||
<div className="mb-2 text-xs font-medium">ตัวกรอง</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<select
|
||||
className="rounded-xl border p-2 text-sm"
|
||||
className="p-2 text-sm border rounded-xl"
|
||||
value={filters.type}
|
||||
onChange={(e) =>
|
||||
setFilters((f) => ({ ...f, type: e.target.value }))
|
||||
@@ -458,7 +458,7 @@ export default function DashboardPage() {
|
||||
<option>Correspondence</option>
|
||||
</select>
|
||||
<select
|
||||
className="rounded-xl border p-2 text-sm"
|
||||
className="p-2 text-sm border rounded-xl"
|
||||
value={filters.status}
|
||||
onChange={(e) =>
|
||||
setFilters((f) => ({ ...f, status: e.target.value }))
|
||||
@@ -469,7 +469,7 @@ export default function DashboardPage() {
|
||||
<option>Review</option>
|
||||
<option>Sent</option>
|
||||
</select>
|
||||
<label className="col-span-2 flex items-center gap-2 text-sm">
|
||||
<label className="flex items-center col-span-2 gap-2 text-sm">
|
||||
<Switch
|
||||
checked={filters.overdue}
|
||||
onCheckedChange={(v) =>
|
||||
@@ -479,14 +479,14 @@ export default function DashboardPage() {
|
||||
แสดงเฉพาะ Overdue
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-xl"
|
||||
style={{ borderColor: sea.mid, color: sea.dark }}
|
||||
>
|
||||
<Filter className="h-4 w-4 mr-1" />
|
||||
<Filter className="w-4 h-4 mr-1" />
|
||||
Apply
|
||||
</Button>
|
||||
<Button
|
||||
@@ -518,8 +518,8 @@ export default function DashboardPage() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-5 text-xs opacity-70 flex items-center gap-2">
|
||||
<Database className="h-4 w-4" /> dms_db • MariaDB 10.11
|
||||
<div className="flex items-center gap-2 mt-5 text-xs opacity-70">
|
||||
<Database className="w-4 h-4" /> dms_db • MariaDB 10.11
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -535,7 +535,7 @@ export default function DashboardPage() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.05, duration: 0.4 }}
|
||||
>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{kpis.map((k) => (
|
||||
<KPI key={k.key} {...k} onClick={() => onKpiClick(k.query)} />
|
||||
))}
|
||||
@@ -555,7 +555,7 @@ export default function DashboardPage() {
|
||||
style={{ borderColor: sea.mid, color: sea.dark }}
|
||||
onClick={() => setDensityCompact((v) => !v)}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4 mr-1" /> Density:{" "}
|
||||
<SlidersHorizontal className="w-4 h-4 mr-1" /> Density:{" "}
|
||||
{densityCompact ? "Compact" : "Comfort"}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
@@ -566,7 +566,7 @@ export default function DashboardPage() {
|
||||
className="rounded-xl"
|
||||
style={{ borderColor: sea.mid, color: sea.dark }}
|
||||
>
|
||||
<Columns3 className="h-4 w-4 mr-1" /> Columns
|
||||
<Columns3 className="w-4 h-4 mr-1" /> Columns
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
@@ -578,9 +578,9 @@ export default function DashboardPage() {
|
||||
}
|
||||
>
|
||||
{showCols[key] ? (
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4 mr-2" />
|
||||
<EyeOff className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{key}
|
||||
</DropdownMenuItem>
|
||||
@@ -590,7 +590,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="rounded-2xl border-0">
|
||||
<Card className="border-0 rounded-2xl">
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table
|
||||
@@ -606,22 +606,22 @@ export default function DashboardPage() {
|
||||
}}
|
||||
>
|
||||
<tr className="text-left">
|
||||
{showCols.type && <th className="py-2 px-3">ประเภท</th>}
|
||||
{showCols.id && <th className="py-2 px-3">รหัส</th>}
|
||||
{showCols.type && <th className="px-3 py-2">ประเภท</th>}
|
||||
{showCols.id && <th className="px-3 py-2">รหัส</th>}
|
||||
{showCols.title && (
|
||||
<th className="py-2 px-3">ชื่อเรื่อง</th>
|
||||
<th className="px-3 py-2">ชื่อเรื่อง</th>
|
||||
)}
|
||||
{showCols.status && (
|
||||
<th className="py-2 px-3">สถานะ</th>
|
||||
<th className="px-3 py-2">สถานะ</th>
|
||||
)}
|
||||
{showCols.due && (
|
||||
<th className="py-2 px-3">กำหนดส่ง</th>
|
||||
<th className="px-3 py-2">กำหนดส่ง</th>
|
||||
)}
|
||||
{showCols.owner && (
|
||||
<th className="py-2 px-3">ผู้รับผิดชอบ</th>
|
||||
<th className="px-3 py-2">ผู้รับผิดชอบ</th>
|
||||
)}
|
||||
{showCols.actions && (
|
||||
<th className="py-2 px-3">จัดการ</th>
|
||||
<th className="px-3 py-2">จัดการ</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -629,7 +629,7 @@ export default function DashboardPage() {
|
||||
{visibleItems.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
className="py-8 px-3 text-center opacity-70"
|
||||
className="px-3 py-8 text-center opacity-70"
|
||||
colSpan={7}
|
||||
>
|
||||
ไม่พบรายการตามตัวกรองที่เลือก
|
||||
@@ -639,32 +639,32 @@ export default function DashboardPage() {
|
||||
{visibleItems.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className="border-b hover:bg-gray-50/50 cursor-pointer"
|
||||
className="border-b cursor-pointer hover:bg-gray-50/50"
|
||||
style={{ borderColor: "#f3f3f3" }}
|
||||
onClick={() => setPreviewOpen(true)}
|
||||
>
|
||||
{showCols.type && (
|
||||
<td className="py-2 px-3">{row.t}</td>
|
||||
<td className="px-3 py-2">{row.t}</td>
|
||||
)}
|
||||
{showCols.id && (
|
||||
<td className="py-2 px-3 font-mono">{row.id}</td>
|
||||
<td className="px-3 py-2 font-mono">{row.id}</td>
|
||||
)}
|
||||
{showCols.title && (
|
||||
<td className="py-2 px-3">{row.title}</td>
|
||||
<td className="px-3 py-2">{row.title}</td>
|
||||
)}
|
||||
{showCols.status && (
|
||||
<td className="py-2 px-3">
|
||||
<td className="px-3 py-2">
|
||||
<Tag>{row.status}</Tag>
|
||||
</td>
|
||||
)}
|
||||
{showCols.due && (
|
||||
<td className="py-2 px-3">{row.due}</td>
|
||||
<td className="px-3 py-2">{row.due}</td>
|
||||
)}
|
||||
{showCols.owner && (
|
||||
<td className="py-2 px-3">{row.owner}</td>
|
||||
<td className="px-3 py-2">{row.owner}</td>
|
||||
)}
|
||||
{showCols.actions && (
|
||||
<td className="py-2 px-3">
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -692,7 +692,7 @@ export default function DashboardPage() {
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
className="px-4 py-2 text-xs opacity-70 border-t"
|
||||
className="px-4 py-2 text-xs border-t opacity-70"
|
||||
style={{ borderColor: "#efefef" }}
|
||||
>
|
||||
เคล็ดลับ: ใช้ปุ่ม ↑/↓ เลื่อนแถว, Enter เปิด, / โฟกัสค้นหา
|
||||
@@ -702,15 +702,15 @@ export default function DashboardPage() {
|
||||
|
||||
<Tabs defaultValue="overview" className="w-full">
|
||||
<TabsList
|
||||
className="rounded-2xl border bg-white/80"
|
||||
className="border rounded-2xl bg-white/80"
|
||||
style={{ borderColor: "#ffffff80" }}
|
||||
>
|
||||
<TabsTrigger value="overview">ภาพรวม</TabsTrigger>
|
||||
<TabsTrigger value="reports">รายงาน</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview" className="mt-4 space-y-4">
|
||||
<div className="grid lg:grid-cols-5 gap-4">
|
||||
<Card className="rounded-2xl border-0 lg:col-span-3">
|
||||
<div className="grid gap-4 lg:grid-cols-5">
|
||||
<Card className="border-0 rounded-2xl lg:col-span-3">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
@@ -730,7 +730,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div
|
||||
className="rounded-xl p-4 border"
|
||||
className="p-4 border rounded-xl"
|
||||
style={{
|
||||
background: sea.light,
|
||||
borderColor: sea.light,
|
||||
@@ -742,7 +742,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="rounded-xl p-4 border"
|
||||
className="p-4 border rounded-xl"
|
||||
style={{
|
||||
background: sea.light,
|
||||
borderColor: sea.light,
|
||||
@@ -754,7 +754,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="rounded-xl p-4 border"
|
||||
className="p-4 border rounded-xl"
|
||||
style={{
|
||||
background: sea.light,
|
||||
borderColor: sea.light,
|
||||
@@ -771,7 +771,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="rounded-2xl border-0 lg:col-span-2">
|
||||
<Card className="border-0 rounded-2xl lg:col-span-2">
|
||||
<CardContent className="p-5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
@@ -784,7 +784,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4" /> Nginx Reverse Proxy{" "}
|
||||
<Server className="w-4 h-4" /> Nginx Reverse Proxy{" "}
|
||||
<span
|
||||
className="ml-auto font-medium"
|
||||
style={{ color: sea.dark }}
|
||||
@@ -793,7 +793,7 @@ export default function DashboardPage() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4" /> MariaDB 10.11{" "}
|
||||
<Database className="w-4 h-4" /> MariaDB 10.11{" "}
|
||||
<span
|
||||
className="ml-auto font-medium"
|
||||
style={{ color: sea.dark }}
|
||||
@@ -802,7 +802,7 @@ export default function DashboardPage() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Workflow className="h-4 w-4" /> n8n (Postgres){" "}
|
||||
<Workflow className="w-4 h-4" /> n8n (Postgres){" "}
|
||||
<span
|
||||
className="ml-auto font-medium"
|
||||
style={{ color: sea.dark }}
|
||||
@@ -811,7 +811,7 @@ export default function DashboardPage() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" /> RBAC Enforcement{" "}
|
||||
<Shield className="w-4 h-4" /> RBAC Enforcement{" "}
|
||||
<span
|
||||
className="ml-auto font-medium"
|
||||
style={{ color: sea.dark }}
|
||||
@@ -835,7 +835,7 @@ export default function DashboardPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Card className="rounded-2xl border-0">
|
||||
<Card className="border-0 rounded-2xl">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div
|
||||
@@ -850,11 +850,11 @@ export default function DashboardPage() {
|
||||
<Tag>Viewer</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 xl:grid-cols-4 gap-3">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{recent.map((r) => (
|
||||
<div
|
||||
key={r.code}
|
||||
className="rounded-2xl p-4 border hover:shadow-sm transition"
|
||||
className="p-4 transition border rounded-2xl hover:shadow-sm"
|
||||
style={{
|
||||
background: "white",
|
||||
borderColor: "#efefef",
|
||||
@@ -864,12 +864,12 @@ export default function DashboardPage() {
|
||||
{r.type} • {r.code}
|
||||
</div>
|
||||
<div
|
||||
className="font-medium mt-1"
|
||||
className="mt-1 font-medium"
|
||||
style={{ color: sea.textDark }}
|
||||
>
|
||||
{r.title}
|
||||
</div>
|
||||
<div className="text-xs mt-2 opacity-70">{r.who}</div>
|
||||
<div className="mt-2 text-xs opacity-70">{r.who}</div>
|
||||
<div className="text-xs opacity-70">{r.when}</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -878,11 +878,11 @@ export default function DashboardPage() {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="reports" className="mt-4">
|
||||
<div className="grid lg:grid-cols-2 gap-4">
|
||||
<Card className="rounded-2xl border-0">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card className="border-0 rounded-2xl">
|
||||
<CardContent className="p-5">
|
||||
<div
|
||||
className="font-semibold mb-2"
|
||||
className="mb-2 font-semibold"
|
||||
style={{ color: sea.textDark }}
|
||||
>
|
||||
Report A: RFA → Drawings → Revisions
|
||||
@@ -897,10 +897,10 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="rounded-2xl border-0">
|
||||
<Card className="border-0 rounded-2xl">
|
||||
<CardContent className="p-5">
|
||||
<div
|
||||
className="font-semibold mb-2"
|
||||
className="mb-2 font-semibold"
|
||||
style={{ color: sea.textDark }}
|
||||
>
|
||||
Report B: ไทม์ไลน์ RFA vs Drawing Rev
|
||||
@@ -919,7 +919,7 @@ export default function DashboardPage() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="text-xs opacity-70 text-center py-6">
|
||||
<div className="py-6 text-xs text-center opacity-70">
|
||||
Sea-themed Dashboard • Sidebar ซ่อนได้ • RBAC แสดง/ซ่อน • Faceted
|
||||
search • KPI click-through • Preview drawer • Column
|
||||
visibility/Density
|
||||
@@ -942,7 +942,7 @@ export default function DashboardPage() {
|
||||
<div>
|
||||
<span className="opacity-70">แนบไฟล์:</span> 2 รายการ (PDF, DWG)
|
||||
</div>
|
||||
<div className="pt-2 flex gap-2">
|
||||
<div className="flex gap-2 pt-2">
|
||||
{can(user, "rfa:create") && (
|
||||
<Button className="btn-sea rounded-xl">แก้ไข</Button>
|
||||
)}
|
||||
|
||||
@@ -1,110 +1,110 @@
|
||||
+ "use client";
|
||||
+ import React from "react";
|
||||
+ import { useRouter } from "next/navigation";
|
||||
+ import { api } from "@/lib/api";
|
||||
+ import { Input } from "@/components/ui/input";
|
||||
+ import { Button } from "@/components/ui/button";
|
||||
+
|
||||
+ export default function RfaNew() {
|
||||
+ const router = useRouter();
|
||||
+ const [draftId, setDraftId] = React.useState(null);
|
||||
+ const [saving, setSaving] = React.useState(false);
|
||||
+ const [savedAt, setSavedAt] = React.useState(null);
|
||||
+ const [error, setError] = React.useState("");
|
||||
+ const [form, setForm] = React.useState({
|
||||
+ title: "", code: "", discipline: "", due_date: "", description: ""
|
||||
+ });
|
||||
+ const [errs, setErrs] = React.useState({});
|
||||
+
|
||||
+ // simple validate (client)
|
||||
+ const validate = (f) => {
|
||||
+ const e = {};
|
||||
+ if (!f.title?.trim()) e.title = "กรุณากรอกชื่อเรื่อง";
|
||||
+ if (!f.due_date) e.due_date = "กรุณากำหนดวันที่ครบกำหนด";
|
||||
+ return e;
|
||||
+ };
|
||||
+
|
||||
+ // debounce autosave
|
||||
+ const tRef = React.useRef(0);
|
||||
+ React.useEffect(() => {
|
||||
+ clearTimeout(tRef.current);
|
||||
+ tRef.current = window.setTimeout(async () => {
|
||||
+ const e = validate(form);
|
||||
+ setErrs(e); // แสดง error ทันที (soft)
|
||||
+ try {
|
||||
+ setSaving(true);
|
||||
+ if (!draftId) {
|
||||
+ // create draft
|
||||
+ const res = await api("/rfas", { method: "POST", body: { ...form, status: "draft" } });
|
||||
+ setDraftId(res.id);
|
||||
+ } else {
|
||||
+ // update draft
|
||||
+ await api(`/rfas/${draftId}`, { method: "PATCH", body: { ...form, status: "draft" } });
|
||||
+ }
|
||||
+ setSavedAt(new Date());
|
||||
+ } catch (err) {
|
||||
+ setError(err.message || "บันทึกฉบับร่างไม่สำเร็จ");
|
||||
+ } finally {
|
||||
+ setSaving(false);
|
||||
+ }
|
||||
+ }, 800);
|
||||
+ return () => clearTimeout(tRef.current);
|
||||
+ }, [form, draftId]);
|
||||
+
|
||||
+ const onSubmit = async (e) => {
|
||||
+ e.preventDefault();
|
||||
+ const eobj = validate(form);
|
||||
+ setErrs(eobj);
|
||||
+ if (Object.keys(eobj).length) return;
|
||||
+ try {
|
||||
+ setSaving(true);
|
||||
+ const id = draftId
|
||||
+ ? (await api(`/rfas/${draftId}`, { method: "PATCH", body: { ...form, status: "submitted" } })).id || draftId
|
||||
+ : (await api("/rfas", { method: "POST", body: { ...form, status: "submitted" } })).id;
|
||||
+ router.replace(`/rfas`); // หรือไปหน้า detail `/rfas/${id}`
|
||||
+ } catch (err) {
|
||||
+ setError(err.message || "ส่งคำขอไม่สำเร็จ");
|
||||
+ } finally {
|
||||
+ setSaving(false);
|
||||
+ }
|
||||
+ };
|
||||
+
|
||||
+ return (
|
||||
+ <form onSubmit={onSubmit} className="space-y-4 rounded-2xl p-5 bg-white">
|
||||
+ <div className="text-lg font-semibold">สร้าง RFA</div>
|
||||
+ {error && <div className="text-sm text-red-600">{error}</div>}
|
||||
+ <div className="grid md:grid-cols-2 gap-3">
|
||||
+ <div>
|
||||
+ <label className="text-sm">ชื่อเรื่อง *</label>
|
||||
+ <Input value={form.title} onChange={(e)=>setForm(f=>({...f, title:e.target.value}))}/>
|
||||
+ {errs.title && <div className="text-xs text-red-600 mt-1">{errs.title}</div>}
|
||||
+ </div>
|
||||
+ <div>
|
||||
+ <label className="text-sm">รหัส (ถ้ามี)</label>
|
||||
+ <Input value={form.code} onChange={(e)=>setForm(f=>({...f, code:e.target.value}))}/>
|
||||
+ </div>
|
||||
+ <div>
|
||||
+ <label className="text-sm">สาขา/หมวด (Discipline)</label>
|
||||
+ <Input value={form.discipline} onChange={(e)=>setForm(f=>({...f, discipline:e.target.value}))}/>
|
||||
+ </div>
|
||||
+ <div>
|
||||
+ <label className="text-sm">กำหนดส่ง *</label>
|
||||
+ <input type="date" className="border rounded-xl p-2 w-full" value={form.due_date}
|
||||
+ onChange={(e)=>setForm(f=>({...f, due_date:e.target.value}))}/>
|
||||
+ {errs.due_date && <div className="text-xs text-red-600 mt-1">{errs.due_date}</div>}
|
||||
+ </div>
|
||||
+ </div>
|
||||
+ <div>
|
||||
+ <label className="text-sm">รายละเอียด</label>
|
||||
+ <textarea rows={5} className="border rounded-xl p-2 w-full"
|
||||
+ value={form.description} onChange={(e)=>setForm(f=>({...f, description:e.target.value}))}/>
|
||||
+ </div>
|
||||
+ <div className="flex items-center gap-3">
|
||||
+ <Button type="submit" disabled={saving}>ส่งเพื่อพิจารณา</Button>
|
||||
+ <span className="text-sm opacity-70">
|
||||
+ {saving ? "กำลังบันทึก…" : savedAt ? `บันทึกล่าสุด ${savedAt.toLocaleTimeString()}` : "ยังไม่เคยบันทึก"}
|
||||
+ </span>
|
||||
+ </div>
|
||||
+ </form>
|
||||
+ );
|
||||
+ }
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function RfaNew() {
|
||||
const router = useRouter();
|
||||
const [draftId, setDraftId] = React.useState(null);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [savedAt, setSavedAt] = React.useState(null);
|
||||
const [error, setError] = React.useState("");
|
||||
const [form, setForm] = React.useState({
|
||||
title: "", code: "", discipline: "", due_date: "", description: ""
|
||||
});
|
||||
const [errs, setErrs] = React.useState({});
|
||||
|
||||
// simple validate (client)
|
||||
const validate = (f) => {
|
||||
const e = {};
|
||||
if (!f.title?.trim()) e.title = "กรุณากรอกชื่อเรื่อง";
|
||||
if (!f.due_date) e.due_date = "กรุณากำหนดวันที่ครบกำหนด";
|
||||
return e;
|
||||
};
|
||||
|
||||
// debounce autosave
|
||||
const tRef = React.useRef(0);
|
||||
React.useEffect(() => {
|
||||
clearTimeout(tRef.current);
|
||||
tRef.current = window.setTimeout(async () => {
|
||||
const e = validate(form);
|
||||
setErrs(e); // แสดง error ทันที (soft)
|
||||
try {
|
||||
setSaving(true);
|
||||
if (!draftId) {
|
||||
// create draft
|
||||
const res = await api("/rfas", { method: "POST", body: { ...form, status: "draft" } });
|
||||
setDraftId(res.id);
|
||||
} else {
|
||||
// update draft
|
||||
await api(`/rfas/${draftId}`, { method: "PATCH", body: { ...form, status: "draft" } });
|
||||
}
|
||||
setSavedAt(new Date());
|
||||
} catch (err) {
|
||||
setError(err.message || "บันทึกฉบับร่างไม่สำเร็จ");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, 800);
|
||||
return () => clearTimeout(tRef.current);
|
||||
}, [form, draftId]);
|
||||
|
||||
const onSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const eobj = validate(form);
|
||||
setErrs(eobj);
|
||||
if (Object.keys(eobj).length) return;
|
||||
try {
|
||||
setSaving(true);
|
||||
const id = draftId
|
||||
? (await api(`/rfas/${draftId}`, { method: "PATCH", body: { ...form, status: "submitted" } })).id || draftId
|
||||
: (await api("/rfas", { method: "POST", body: { ...form, status: "submitted" } })).id;
|
||||
router.replace(`/rfas`); // หรือไปหน้า detail `/rfas/${id}`
|
||||
} catch (err) {
|
||||
setError(err.message || "ส่งคำขอไม่สำเร็จ");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="p-5 space-y-4 bg-white rounded-2xl">
|
||||
<div className="text-lg font-semibold">สร้าง RFA</div>
|
||||
{error && <div className="text-sm text-red-600">{error}</div>}
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm">ชื่อเรื่อง *</label>
|
||||
<Input value={form.title} onChange={(e)=>setForm(f=>({...f, title:e.target.value}))}/>
|
||||
{errs.title && <div className="mt-1 text-xs text-red-600">{errs.title}</div>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm">รหัส (ถ้ามี)</label>
|
||||
<Input value={form.code} onChange={(e)=>setForm(f=>({...f, code:e.target.value}))}/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm">สาขา/หมวด (Discipline)</label>
|
||||
<Input value={form.discipline} onChange={(e)=>setForm(f=>({...f, discipline:e.target.value}))}/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm">กำหนดส่ง *</label>
|
||||
<input type="date" className="w-full p-2 border rounded-xl" value={form.due_date}
|
||||
onChange={(e)=>setForm(f=>({...f, due_date:e.target.value}))}/>
|
||||
{errs.due_date && <div className="mt-1 text-xs text-red-600">{errs.due_date}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm">รายละเอียด</label>
|
||||
<textarea rows={5} className="w-full p-2 border rounded-xl"
|
||||
value={form.description} onChange={(e)=>setForm(f=>({...f, description:e.target.value}))}/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="submit" disabled={saving}>ส่งเพื่อพิจารณา</Button>
|
||||
<span className="text-sm opacity-70">
|
||||
{saving ? "กำลังบันทึก…" : savedAt ? `บันทึกล่าสุด ${savedAt.toLocaleTimeString()}` : "ยังไม่เคยบันทึก"}
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +1,135 @@
|
||||
+ "use client";
|
||||
+ import React from "react";
|
||||
+ import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||
+ import { apiGet } from "@/lib/api";
|
||||
+ import { Input } from "@/components/ui/input";
|
||||
+ import { Button } from "@/components/ui/button";
|
||||
+ import { Card, CardContent } from "@/components/ui/card";
|
||||
+
|
||||
+ export default function RFAsPage() {
|
||||
+ const router = useRouter();
|
||||
+ const pathname = usePathname();
|
||||
+ const sp = useSearchParams();
|
||||
+
|
||||
+ // params from URL
|
||||
+ const [q, setQ] = React.useState(sp.get("q") || "");
|
||||
+ const status = sp.get("status") || "All";
|
||||
+ const overdue = sp.get("overdue") === "1";
|
||||
+ const page = Number(sp.get("page") || 1);
|
||||
+ const pageSize = Number(sp.get("pageSize") || 20);
|
||||
+ const sort = sp.get("sort") || "updated_at:desc";
|
||||
+
|
||||
+ const setParams = (patch) => {
|
||||
+ const curr = Object.fromEntries(sp.entries());
|
||||
+ const next = { ...curr, ...patch };
|
||||
+ // normalize
|
||||
+ if (!next.q) delete next.q;
|
||||
+ if (!next.status || next.status === "All") delete next.status;
|
||||
+ if (!next.overdue || next.overdue === "0") delete next.overdue;
|
||||
+ if (!next.page || Number(next.page) === 1) delete next.page;
|
||||
+ if (!next.pageSize || Number(next.pageSize) === 20) delete next.pageSize;
|
||||
+ if (!next.sort || next.sort === "updated_at:desc") delete next.sort;
|
||||
+ const usp = new URLSearchParams(next).toString();
|
||||
+ router.replace(`${pathname}${usp ? `?${usp}` : ""}`);
|
||||
+ };
|
||||
+
|
||||
+ const [rows, setRows] = React.useState([]);
|
||||
+ const [total, setTotal] = React.useState(0);
|
||||
+ const [loading, setLoading] = React.useState(true);
|
||||
+ const [error, setError] = React.useState("");
|
||||
+
|
||||
+ // fetch whenever URL params change
|
||||
+ React.useEffect(() => {
|
||||
+ setLoading(true); setError("");
|
||||
+ apiGet("/rfas", {
|
||||
+ q, status: status !== "All" ? status : undefined,
|
||||
+ overdue: overdue ? 1 : undefined, page, pageSize, sort
|
||||
+ }).then((res) => {
|
||||
+ // expected: { data: [...], page, pageSize, total }
|
||||
+ setRows(res.data || []);
|
||||
+ setTotal(res.total || 0);
|
||||
+ }).catch((e) => {
|
||||
+ setError(e.message || "โหลดข้อมูลไม่สำเร็จ");
|
||||
+ }).finally(() => setLoading(false));
|
||||
+ // eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
+ }, [sp]);
|
||||
+
|
||||
+ const pages = Math.max(1, Math.ceil(total / pageSize));
|
||||
+
|
||||
+ return (
|
||||
+ <div className="space-y-4">
|
||||
+ <div className="flex items-center gap-2">
|
||||
+ <Input
|
||||
+ placeholder="ค้นหา (รหัส/ชื่อเรื่อง/ผู้รับผิดชอบ)"
|
||||
+ value={q}
|
||||
+ onChange={(e) => setQ(e.target.value)}
|
||||
+ onKeyDown={(e) => e.key === "Enter" && setParams({ q, page: 1 })}
|
||||
+ />
|
||||
+ <select
|
||||
+ className="border rounded-xl p-2"
|
||||
+ value={status}
|
||||
+ onChange={(e) => setParams({ status: e.target.value, page: 1 })}
|
||||
+ >
|
||||
+ <option>All</option><option>Pending</option><option>Review</option><option>Approved</option><option>Closed</option>
|
||||
+ </select>
|
||||
+ <label className="text-sm flex items-center gap-2">
|
||||
+ <input
|
||||
+ type="checkbox"
|
||||
+ checked={overdue}
|
||||
+ onChange={(e) => setParams({ overdue: e.target.checked ? "1" : "0", page: 1 })}
|
||||
+ />
|
||||
+ Overdue
|
||||
+ </label>
|
||||
+ <Button onClick={() => setParams({ q, page: 1 })}>ค้นหา</Button>
|
||||
+ </div>
|
||||
+
|
||||
+ <Card className="rounded-2xl border-0">
|
||||
+ <CardContent className="p-0">
|
||||
+ <div className="overflow-x-auto">
|
||||
+ <table className="min-w-full text-sm">
|
||||
+ <thead className="bg-white sticky top-0 border-b">
|
||||
+ <tr className="text-left">
|
||||
+ <th className="py-2 px-3">รหัส</th>
|
||||
+ <th className="py-2 px-3">ชื่อเรื่อง</th>
|
||||
+ <th className="py-2 px-3">สถานะ</th>
|
||||
+ <th className="py-2 px-3">กำหนดส่ง</th>
|
||||
+ <th className="py-2 px-3">ผู้รับผิดชอบ</th>
|
||||
+ </tr>
|
||||
+ </thead>
|
||||
+ <tbody>
|
||||
+ {loading && <tr><td className="py-6 px-3" colSpan={5}>กำลังโหลด…</td></tr>}
|
||||
+ {error && !loading && <tr><td className="py-6 px-3 text-red-600" colSpan={5}>{error}</td></tr>}
|
||||
+ {!loading && !error && rows.length === 0 && <tr><td className="py-6 px-3 opacity-70" colSpan={5}>ไม่พบข้อมูล</td></tr>}
|
||||
+ {!loading && !error && rows.map((r) => (
|
||||
+ <tr key={r.id} className="border-b hover:bg-gray-50">
|
||||
+ <td className="py-2 px-3 font-mono">{r.code || r.id}</td>
|
||||
+ <td className="py-2 px-3">{r.title}</td>
|
||||
+ <td className="py-2 px-3">{r.status}</td>
|
||||
+ <td className="py-2 px-3">{r.due_date || "—"}</td>
|
||||
+ <td className="py-2 px-3">{r.owner_name || "—"}</td>
|
||||
+ </tr>
|
||||
+ ))}
|
||||
+ </tbody>
|
||||
+ </table>
|
||||
+ </div>
|
||||
+ <div className="flex items-center justify-between px-3 py-2 text-sm border-t">
|
||||
+ <span>ทั้งหมด {total} รายการ</span>
|
||||
+ <div className="flex items-center gap-2">
|
||||
+ <Button
|
||||
+ variant="outline"
|
||||
+ onClick={() => setParams({ page: Math.max(1, page - 1) })}
|
||||
+ disabled={page <= 1}
|
||||
+ >ย้อนกลับ</Button>
|
||||
+ <span>หน้า {page}/{pages}</span>
|
||||
+ <Button
|
||||
+ variant="outline"
|
||||
+ onClick={() => setParams({ page: Math.min(pages, page + 1) })}
|
||||
+ disabled={page >= pages}
|
||||
+ >ถัดไป</Button>
|
||||
+ </div>
|
||||
+ </div>
|
||||
+ </CardContent>
|
||||
+ </Card>
|
||||
+ </div>
|
||||
+ );
|
||||
+ }
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||
import { apiGet } from "@/lib/api";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default function RFAsPage() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const sp = useSearchParams();
|
||||
|
||||
// params from URL
|
||||
const [q, setQ] = React.useState(sp.get("q") || "");
|
||||
const status = sp.get("status") || "All";
|
||||
const overdue = sp.get("overdue") === "1";
|
||||
const page = Number(sp.get("page") || 1);
|
||||
const pageSize = Number(sp.get("pageSize") || 20);
|
||||
const sort = sp.get("sort") || "updated_at:desc";
|
||||
|
||||
const setParams = (patch) => {
|
||||
const curr = Object.fromEntries(sp.entries());
|
||||
const next = { ...curr, ...patch };
|
||||
// normalize
|
||||
if (!next.q) delete next.q;
|
||||
if (!next.status || next.status === "All") delete next.status;
|
||||
if (!next.overdue || next.overdue === "0") delete next.overdue;
|
||||
if (!next.page || Number(next.page) === 1) delete next.page;
|
||||
if (!next.pageSize || Number(next.pageSize) === 20) delete next.pageSize;
|
||||
if (!next.sort || next.sort === "updated_at:desc") delete next.sort;
|
||||
const usp = new URLSearchParams(next).toString();
|
||||
router.replace(`${pathname}${usp ? `?${usp}` : ""}`);
|
||||
};
|
||||
|
||||
const [rows, setRows] = React.useState([]);
|
||||
const [total, setTotal] = React.useState(0);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState("");
|
||||
|
||||
// fetch whenever URL params change
|
||||
React.useEffect(() => {
|
||||
setLoading(true); setError("");
|
||||
apiGet("/rfas", {
|
||||
q, status: status !== "All" ? status : undefined,
|
||||
overdue: overdue ? 1 : undefined, page, pageSize, sort
|
||||
}).then((res) => {
|
||||
// expected: { data: [...], page, pageSize, total }
|
||||
setRows(res.data || []);
|
||||
setTotal(res.total || 0);
|
||||
}).catch((e) => {
|
||||
setError(e.message || "โหลดข้อมูลไม่สำเร็จ");
|
||||
}).finally(() => setLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sp]);
|
||||
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="ค้นหา (รหัส/ชื่อเรื่อง/ผู้รับผิดชอบ)"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && setParams({ q, page: 1 })}
|
||||
/>
|
||||
<select
|
||||
className="border rounded-xl p-2"
|
||||
value={status}
|
||||
onChange={(e) => setParams({ status: e.target.value, page: 1 })}
|
||||
>
|
||||
<option>All</option><option>Pending</option><option>Review</option><option>Approved</option><option>Closed</option>
|
||||
</select>
|
||||
<label className="text-sm flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={overdue}
|
||||
onChange={(e) => setParams({ overdue: e.target.checked ? "1" : "0", page: 1 })}
|
||||
/>
|
||||
Overdue
|
||||
</label>
|
||||
<Button onClick={() => setParams({ q, page: 1 })}>ค้นหา</Button>
|
||||
</div>
|
||||
|
||||
<Card className="rounded-2xl border-0">
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-white sticky top-0 border-b">
|
||||
<tr className="text-left">
|
||||
<th className="py-2 px-3">รหัส</th>
|
||||
<th className="py-2 px-3">ชื่อเรื่อง</th>
|
||||
<th className="py-2 px-3">สถานะ</th>
|
||||
<th className="py-2 px-3">กำหนดส่ง</th>
|
||||
<th className="py-2 px-3">ผู้รับผิดชอบ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && <tr><td className="py-6 px-3" colSpan={5}>กำลังโหลด…</td></tr>}
|
||||
{error && !loading && <tr><td className="py-6 px-3 text-red-600" colSpan={5}>{error}</td></tr>}
|
||||
{!loading && !error && rows.length === 0 && <tr><td className="py-6 px-3 opacity-70" colSpan={5}>ไม่พบข้อมูล</td></tr>}
|
||||
{!loading && !error && rows.map((r) => (
|
||||
<tr key={r.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-2 px-3 font-mono">{r.code || r.id}</td>
|
||||
<td className="py-2 px-3">{r.title}</td>
|
||||
<td className="py-2 px-3">{r.status}</td>
|
||||
<td className="py-2 px-3">{r.due_date || "—"}</td>
|
||||
<td className="py-2 px-3">{r.owner_name || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-3 py-2 text-sm border-t">
|
||||
<span>ทั้งหมด {total} รายการ</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setParams({ page: Math.max(1, page - 1) })}
|
||||
disabled={page <= 1}
|
||||
>ย้อนกลับ</Button>
|
||||
<span>หน้า {page}/{pages}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setParams({ page: Math.min(pages, page + 1) })}
|
||||
disabled={page >= pages}
|
||||
>ถัดไป</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +1,108 @@
|
||||
+ "use client";
|
||||
+ import React from "react";
|
||||
+ import { useRouter } from "next/navigation";
|
||||
+ import { api } from "@/lib/api";
|
||||
+ import { Input } from "@/components/ui/input";
|
||||
+ import { Button } from "@/components/ui/button";
|
||||
+
|
||||
+ export default function TransmittalNew() {
|
||||
+ const router = useRouter();
|
||||
+ const [draftId, setDraftId] = React.useState(null);
|
||||
+ const [saving, setSaving] = React.useState(false);
|
||||
+ const [savedAt, setSavedAt] = React.useState(null);
|
||||
+ const [error, setError] = React.useState("");
|
||||
+ const [form, setForm] = React.useState({
|
||||
+ subject: "", number: "", to_party: "", sent_date: "", description: ""
|
||||
+ });
|
||||
+ const [errs, setErrs] = React.useState({});
|
||||
+
|
||||
+ const validate = (f) => {
|
||||
+ const e = {};
|
||||
+ if (!f.subject?.trim()) e.subject = "กรุณากรอกเรื่อง (Subject)";
|
||||
+ if (!f.to_party?.trim()) e.to_party = "กรุณาระบุผู้รับ (To)";
|
||||
+ if (!f.sent_date) e.sent_date = "กรุณาระบุวันที่ส่ง";
|
||||
+ return e;
|
||||
+ };
|
||||
+
|
||||
+ const tRef = React.useRef(0);
|
||||
+ React.useEffect(() => {
|
||||
+ clearTimeout(tRef.current);
|
||||
+ tRef.current = window.setTimeout(async () => {
|
||||
+ const e = validate(form);
|
||||
+ setErrs(e);
|
||||
+ try {
|
||||
+ setSaving(true);
|
||||
+ if (!draftId) {
|
||||
+ const res = await api("/transmittals", { method: "POST", body: { ...form, status: "draft" } });
|
||||
+ setDraftId(res.id);
|
||||
+ } else {
|
||||
+ await api(`/transmittals/${draftId}`, { method: "PATCH", body: { ...form, status: "draft" } });
|
||||
+ }
|
||||
+ setSavedAt(new Date());
|
||||
+ } catch (err) {
|
||||
+ setError(err.message || "บันทึกฉบับร่างไม่สำเร็จ");
|
||||
+ } finally {
|
||||
+ setSaving(false);
|
||||
+ }
|
||||
+ }, 800);
|
||||
+ return () => clearTimeout(tRef.current);
|
||||
+ }, [form, draftId]);
|
||||
+
|
||||
+ const onSubmit = async (e) => {
|
||||
+ e.preventDefault();
|
||||
+ const eobj = validate(form);
|
||||
+ setErrs(eobj);
|
||||
+ if (Object.keys(eobj).length) return;
|
||||
+ try {
|
||||
+ setSaving(true);
|
||||
+ const id = draftId
|
||||
+ ? (await api(`/transmittals/${draftId}`, { method: "PATCH", body: { ...form, status: "submitted" } })).id || draftId
|
||||
+ : (await api("/transmittals", { method: "POST", body: { ...form, status: "submitted" } })).id;
|
||||
+ router.replace(`/transmittals`);
|
||||
+ } catch (err) {
|
||||
+ setError(err.message || "ส่ง Transmittal ไม่สำเร็จ");
|
||||
+ } finally {
|
||||
+ setSaving(false);
|
||||
+ }
|
||||
+ };
|
||||
+
|
||||
+ return (
|
||||
+ <form onSubmit={onSubmit} className="space-y-4 rounded-2xl p-5 bg-white">
|
||||
+ <div className="text-lg font-semibold">สร้าง Transmittal</div>
|
||||
+ {error && <div className="text-sm text-red-600">{error}</div>}
|
||||
+ <div className="grid md:grid-cols-2 gap-3">
|
||||
+ <div>
|
||||
+ <label className="text-sm">เรื่อง (Subject) *</label>
|
||||
+ <Input value={form.subject} onChange={(e)=>setForm(f=>({...f, subject:e.target.value}))}/>
|
||||
+ {errs.subject && <div className="text-xs text-red-600 mt-1">{errs.subject}</div>}
|
||||
+ </div>
|
||||
+ <div>
|
||||
+ <label className="text-sm">เลขที่ (ถ้ามี)</label>
|
||||
+ <Input value={form.number} onChange={(e)=>setForm(f=>({...f, number:e.target.value}))}/>
|
||||
+ </div>
|
||||
+ <div>
|
||||
+ <label className="text-sm">ถึง (To) *</label>
|
||||
+ <Input value={form.to_party} onChange={(e)=>setForm(f=>({...f, to_party:e.target.value}))}/>
|
||||
+ {errs.to_party && <div className="text-xs text-red-600 mt-1">{errs.to_party}</div>}
|
||||
+ </div>
|
||||
+ <div>
|
||||
+ <label className="text-sm">วันที่ส่ง *</label>
|
||||
+ <input type="date" className="border rounded-xl p-2 w-full" value={form.sent_date}
|
||||
+ onChange={(e)=>setForm(f=>({...f, sent_date:e.target.value}))}/>
|
||||
+ {errs.sent_date && <div className="text-xs text-red-600 mt-1">{errs.sent_date}</div>}
|
||||
+ </div>
|
||||
+ </div>
|
||||
+ <div>
|
||||
+ <label className="text-sm">รายละเอียด</label>
|
||||
+ <textarea rows={5} className="border rounded-xl p-2 w-full"
|
||||
+ value={form.description} onChange={(e)=>setForm(f=>({...f, description:e.target.value}))}/>
|
||||
+ </div>
|
||||
+ <div className="flex items-center gap-3">
|
||||
+ <Button type="submit" disabled={saving}>ส่ง Transmittal</Button>
|
||||
+ <span className="text-sm opacity-70">
|
||||
+ {saving ? "กำลังบันทึก…" : savedAt ? `บันทึกล่าสุด ${savedAt.toLocaleTimeString()}` : "ยังไม่เคยบันทึก"}
|
||||
+ </span>
|
||||
+ </div>
|
||||
+ </form>
|
||||
+ );
|
||||
+ }
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function TransmittalNew() {
|
||||
const router = useRouter();
|
||||
const [draftId, setDraftId] = React.useState(null);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [savedAt, setSavedAt] = React.useState(null);
|
||||
const [error, setError] = React.useState("");
|
||||
const [form, setForm] = React.useState({
|
||||
subject: "", number: "", to_party: "", sent_date: "", description: ""
|
||||
});
|
||||
const [errs, setErrs] = React.useState({});
|
||||
|
||||
const validate = (f) => {
|
||||
const e = {};
|
||||
if (!f.subject?.trim()) e.subject = "กรุณากรอกเรื่อง (Subject)";
|
||||
if (!f.to_party?.trim()) e.to_party = "กรุณาระบุผู้รับ (To)";
|
||||
if (!f.sent_date) e.sent_date = "กรุณาระบุวันที่ส่ง";
|
||||
return e;
|
||||
};
|
||||
|
||||
const tRef = React.useRef(0);
|
||||
React.useEffect(() => {
|
||||
clearTimeout(tRef.current);
|
||||
tRef.current = window.setTimeout(async () => {
|
||||
const e = validate(form);
|
||||
setErrs(e);
|
||||
try {
|
||||
setSaving(true);
|
||||
if (!draftId) {
|
||||
const res = await api("/transmittals", { method: "POST", body: { ...form, status: "draft" } });
|
||||
setDraftId(res.id);
|
||||
} else {
|
||||
await api(`/transmittals/${draftId}`, { method: "PATCH", body: { ...form, status: "draft" } });
|
||||
}
|
||||
setSavedAt(new Date());
|
||||
} catch (err) {
|
||||
setError(err.message || "บันทึกฉบับร่างไม่สำเร็จ");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, 800);
|
||||
return () => clearTimeout(tRef.current);
|
||||
}, [form, draftId]);
|
||||
|
||||
const onSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const eobj = validate(form);
|
||||
setErrs(eobj);
|
||||
if (Object.keys(eobj).length) return;
|
||||
try {
|
||||
setSaving(true);
|
||||
const id = draftId
|
||||
? (await api(`/transmittals/${draftId}`, { method: "PATCH", body: { ...form, status: "submitted" } })).id || draftId
|
||||
: (await api("/transmittals", { method: "POST", body: { ...form, status: "submitted" } })).id;
|
||||
router.replace(`/transmittals`);
|
||||
} catch (err) {
|
||||
setError(err.message || "ส่ง Transmittal ไม่สำเร็จ");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-4 rounded-2xl p-5 bg-white">
|
||||
<div className="text-lg font-semibold">สร้าง Transmittal</div>
|
||||
{error && <div className="text-sm text-red-600">{error}</div>}
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-sm">เรื่อง (Subject) *</label>
|
||||
<Input value={form.subject} onChange={(e)=>setForm(f=>({...f, subject:e.target.value}))}/>
|
||||
{errs.subject && <div className="text-xs text-red-600 mt-1">{errs.subject}</div>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm">เลขที่ (ถ้ามี)</label>
|
||||
<Input value={form.number} onChange={(e)=>setForm(f=>({...f, number:e.target.value}))}/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm">ถึง (To) *</label>
|
||||
<Input value={form.to_party} onChange={(e)=>setForm(f=>({...f, to_party:e.target.value}))}/>
|
||||
{errs.to_party && <div className="text-xs text-red-600 mt-1">{errs.to_party}</div>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm">วันที่ส่ง *</label>
|
||||
<input type="date" className="border rounded-xl p-2 w-full" value={form.sent_date}
|
||||
onChange={(e)=>setForm(f=>({...f, sent_date:e.target.value}))}/>
|
||||
{errs.sent_date && <div className="text-xs text-red-600 mt-1">{errs.sent_date}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm">รายละเอียด</label>
|
||||
<textarea rows={5} className="border rounded-xl p-2 w-full"
|
||||
value={form.description} onChange={(e)=>setForm(f=>({...f, description:e.target.value}))}/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="submit" disabled={saving}>ส่ง Transmittal</Button>
|
||||
<span className="text-sm opacity-70">
|
||||
{saving ? "กำลังบันทึก…" : savedAt ? `บันทึกล่าสุด ${savedAt.toLocaleTimeString()}` : "ยังไม่เคยบันทึก"}
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +1,96 @@
|
||||
+ "use client";
|
||||
+ import React from "react";
|
||||
+ import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||
+ import { apiGet } from "@/lib/api";
|
||||
+ import { Input } from "@/components/ui/input";
|
||||
+ import { Button } from "@/components/ui/button";
|
||||
+ import { Card, CardContent } from "@/components/ui/card";
|
||||
+
|
||||
+ export default function TransmittalsPage() {
|
||||
+ const router = useRouter();
|
||||
+ const pathname = usePathname();
|
||||
+ const sp = useSearchParams();
|
||||
+
|
||||
+ const [q, setQ] = React.useState(sp.get("q") || "");
|
||||
+ const page = Number(sp.get("page") || 1);
|
||||
+ const pageSize = Number(sp.get("pageSize") || 20);
|
||||
+ const sort = sp.get("sort") || "sent_date:desc";
|
||||
+
|
||||
+ const setParams = (patch) => {
|
||||
+ const curr = Object.fromEntries(sp.entries());
|
||||
+ const next = { ...curr, ...patch };
|
||||
+ if (!next.q) delete next.q;
|
||||
+ if (!next.page || Number(next.page) === 1) delete next.page;
|
||||
+ if (!next.pageSize || Number(next.pageSize) === 20) delete next.pageSize;
|
||||
+ if (!next.sort || next.sort === "sent_date:desc") delete next.sort;
|
||||
+ const usp = new URLSearchParams(next).toString();
|
||||
+ router.replace(`${pathname}${usp ? `?${usp}` : ""}`);
|
||||
+ };
|
||||
+
|
||||
+ const [rows, setRows] = React.useState([]);
|
||||
+ const [total, setTotal] = React.useState(0);
|
||||
+ const [loading, setLoading] = React.useState(true);
|
||||
+ const [error, setError] = React.useState("");
|
||||
+
|
||||
+ React.useEffect(() => {
|
||||
+ setLoading(true); setError("");
|
||||
+ apiGet("/transmittals", { q, page, pageSize, sort })
|
||||
+ .then((res) => { setRows(res.data || []); setTotal(res.total || 0); })
|
||||
+ .catch((e) => setError(e.message || "โหลดข้อมูลไม่สำเร็จ"))
|
||||
+ .finally(() => setLoading(false));
|
||||
+ // eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
+ }, [sp]);
|
||||
+
|
||||
+ const pages = Math.max(1, Math.ceil(total / pageSize));
|
||||
+
|
||||
+ return (
|
||||
+ <div className="space-y-4">
|
||||
+ <div className="flex items-center gap-2">
|
||||
+ <Input
|
||||
+ placeholder="ค้นหา Transmittal (เลขที่/เรื่อง/ถึงใคร)"
|
||||
+ value={q}
|
||||
+ onChange={(e) => setQ(e.target.value)}
|
||||
+ onKeyDown={(e) => e.key === "Enter" && setParams({ q, page: 1 })}
|
||||
+ />
|
||||
+ <Button onClick={() => setParams({ q, page: 1 })}>ค้นหา</Button>
|
||||
+ </div>
|
||||
+ <Card className="rounded-2xl border-0">
|
||||
+ <CardContent className="p-0">
|
||||
+ <div className="overflow-x-auto">
|
||||
+ <table className="min-w-full text-sm">
|
||||
+ <thead className="bg-white sticky top-0 border-b">
|
||||
+ <tr className="text-left">
|
||||
+ <th className="py-2 px-3">เลขที่</th>
|
||||
+ <th className="py-2 px-3">เรื่อง</th>
|
||||
+ <th className="py-2 px-3">ถึง</th>
|
||||
+ <th className="py-2 px-3">วันที่ส่ง</th>
|
||||
+ </tr>
|
||||
+ </thead>
|
||||
+ <tbody>
|
||||
+ {loading && <tr><td className="py-6 px-3" colSpan={4}>กำลังโหลด…</td></tr>}
|
||||
+ {error && !loading && <tr><td className="py-6 px-3 text-red-600" colSpan={4}>{error}</td></tr>}
|
||||
+ {!loading && !error && rows.length === 0 && <tr><td className="py-6 px-3 opacity-70" colSpan={4}>ไม่พบข้อมูล</td></tr>}
|
||||
+ {!loading && !error && rows.map((r) => (
|
||||
+ <tr key={r.id} className="border-b hover:bg-gray-50">
|
||||
+ <td className="py-2 px-3 font-mono">{r.number || r.id}</td>
|
||||
+ <td className="py-2 px-3">{r.subject}</td>
|
||||
+ <td className="py-2 px-3">{r.to_party}</td>
|
||||
+ <td className="py-2 px-3">{r.sent_date || "—"}</td>
|
||||
+ </tr>
|
||||
+ ))}
|
||||
+ </tbody>
|
||||
+ </table>
|
||||
+ </div>
|
||||
+ <div className="flex items-center justify-between px-3 py-2 text-sm border-t">
|
||||
+ <span>ทั้งหมด {total} รายการ</span>
|
||||
+ <div className="flex items-center gap-2">
|
||||
+ <Button variant="outline" onClick={() => setParams({ page: Math.max(1, page - 1) })} disabled={page <= 1}>ย้อนกลับ</Button>
|
||||
+ <span>หน้า {page}/{pages}</span>
|
||||
+ <Button variant="outline" onClick={() => setParams({ page: Math.min(pages, page + 1) })} disabled={page >= pages}>ถัดไป</Button>
|
||||
+ </div>
|
||||
+ </div>
|
||||
+ </CardContent>
|
||||
+ </Card>
|
||||
+ </div>
|
||||
+ );
|
||||
+ }
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||
import { apiGet } from "@/lib/api";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default function TransmittalsPage() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const sp = useSearchParams();
|
||||
|
||||
const [q, setQ] = React.useState(sp.get("q") || "");
|
||||
const page = Number(sp.get("page") || 1);
|
||||
const pageSize = Number(sp.get("pageSize") || 20);
|
||||
const sort = sp.get("sort") || "sent_date:desc";
|
||||
|
||||
const setParams = (patch) => {
|
||||
const curr = Object.fromEntries(sp.entries());
|
||||
const next = { ...curr, ...patch };
|
||||
if (!next.q) delete next.q;
|
||||
if (!next.page || Number(next.page) === 1) delete next.page;
|
||||
if (!next.pageSize || Number(next.pageSize) === 20) delete next.pageSize;
|
||||
if (!next.sort || next.sort === "sent_date:desc") delete next.sort;
|
||||
const usp = new URLSearchParams(next).toString();
|
||||
router.replace(`${pathname}${usp ? `?${usp}` : ""}`);
|
||||
};
|
||||
|
||||
const [rows, setRows] = React.useState([]);
|
||||
const [total, setTotal] = React.useState(0);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
setLoading(true); setError("");
|
||||
apiGet("/transmittals", { q, page, pageSize, sort })
|
||||
.then((res) => { setRows(res.data || []); setTotal(res.total || 0); })
|
||||
.catch((e) => setError(e.message || "โหลดข้อมูลไม่สำเร็จ"))
|
||||
.finally(() => setLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sp]);
|
||||
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="ค้นหา Transmittal (เลขที่/เรื่อง/ถึงใคร)"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && setParams({ q, page: 1 })}
|
||||
/>
|
||||
<Button onClick={() => setParams({ q, page: 1 })}>ค้นหา</Button>
|
||||
</div>
|
||||
<Card className="border-0 rounded-2xl">
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="sticky top-0 bg-white border-b">
|
||||
<tr className="text-left">
|
||||
<th className="px-3 py-2">เลขที่</th>
|
||||
<th className="px-3 py-2">เรื่อง</th>
|
||||
<th className="px-3 py-2">ถึง</th>
|
||||
<th className="px-3 py-2">วันที่ส่ง</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && <tr><td className="px-3 py-6" colSpan={4}>กำลังโหลด…</td></tr>}
|
||||
{error && !loading && <tr><td className="px-3 py-6 text-red-600" colSpan={4}>{error}</td></tr>}
|
||||
{!loading && !error && rows.length === 0 && <tr><td className="px-3 py-6 opacity-70" colSpan={4}>ไม่พบข้อมูล</td></tr>}
|
||||
{!loading && !error && rows.map((r) => (
|
||||
<tr key={r.id} className="border-b hover:bg-gray-50">
|
||||
<td className="px-3 py-2 font-mono">{r.number || r.id}</td>
|
||||
<td className="px-3 py-2">{r.subject}</td>
|
||||
<td className="px-3 py-2">{r.to_party}</td>
|
||||
<td className="px-3 py-2">{r.sent_date || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-3 py-2 text-sm border-t">
|
||||
<span>ทั้งหมด {total} รายการ</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => setParams({ page: Math.max(1, page - 1) })} disabled={page <= 1}>ย้อนกลับ</Button>
|
||||
<span>หน้า {page}/{pages}</span>
|
||||
<Button variant="outline" onClick={() => setParams({ page: Math.min(pages, page + 1) })} disabled={page >= pages}>ถัดไป</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,6 @@
|
||||
|
||||
/* ====== shadcn/ui theme (light + dark) ====== */
|
||||
:root {
|
||||
--background: 210 40% 98%;
|
||||
--foreground: 220 15% 15%;
|
||||
|
||||
/* โทน “น้ำทะเล” ตามธีมของคุณ */
|
||||
--primary: 199 90% 40%;
|
||||
@@ -78,9 +76,71 @@
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
}
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility: container max width (ช่วยเรื่อง layout) */
|
||||
.container {
|
||||
@apply mx-auto px-4;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
22
frontend/components.json
Normal file
22
frontend/components.json
Normal 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": {}
|
||||
}
|
||||
47
frontend/components/ui/alert.jsx
Normal file
47
frontend/components/ui/alert.jsx
Normal 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 }
|
||||
@@ -1,3 +1,34 @@
|
||||
export function Badge({ className="", ...props }) {
|
||||
return <span className={`inline-flex items-center ${className}`} {...props} />;
|
||||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}) {
|
||||
return (<div className={cn(badgeVariants({ variant }), className)} {...props} />);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
|
||||
@@ -1,3 +1,48 @@
|
||||
export function Button({ className = "", variant, size, ...props }) {
|
||||
return <button className={`px-3 py-2 rounded-xl ${className}`} {...props} />;
|
||||
}
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props} />
|
||||
);
|
||||
})
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
||||
@@ -1,2 +1,50 @@
|
||||
export function Card({ className="", ...props }) { return <div className={`bg-white shadow ${className}`} {...props} />; }
|
||||
export function CardContent({ className="", ...props }) { return <div className={`p-4 ${className}`} {...props} />; }
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
|
||||
{...props} />
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
|
||||
@@ -1,12 +1,158 @@
|
||||
import React from "react";
|
||||
export function DropdownMenu({ children }){ return <div className="relative inline-block">{children}</div>; }
|
||||
export function DropdownMenuTrigger({ asChild, children }){ return children; }
|
||||
export function DropdownMenuContent({ align="end", className="", children }){
|
||||
return <div className={`absolute mt-2 right-0 bg-white shadow rounded-xl p-2 z-50 ${className}`}>{children}</div>;
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...props} />
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props} />
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
export function DropdownMenuItem({ asChild, children, onClick }){
|
||||
const C = asChild ? 'span' : 'button';
|
||||
return <C onClick={onClick} className="block text-left w-full px-2 py-1.5 rounded hover:bg-gray-50">{children}</C>;
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
export function DropdownMenuSeparator(){ return <div className="my-1 border-t"/>; }
|
||||
export function DropdownMenuLabel({ children }){ return <div className="px-2 py-1 text-xs opacity-70">{children}</div>; }
|
||||
@@ -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 }
|
||||
|
||||
18
frontend/components/ui/label.jsx
Normal file
18
frontend/components/ui/label.jsx
Normal 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 }
|
||||
@@ -1,5 +1,23 @@
|
||||
export function Progress({ value=0 }) {
|
||||
return (
|
||||
<div className="h-2 bg-gray-200 rounded-full"><div className="h-2 bg-gray-500 rounded-full" style={{ width: `${value}%` }} /></div>
|
||||
);
|
||||
}
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} />
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
export function Switch({ checked=false, onCheckedChange }){
|
||||
return (
|
||||
<label className="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={checked} onChange={e=>onCheckedChange?.(e.target.checked)} />
|
||||
<span>{checked?"On":"Off"}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)} />
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
|
||||
@@ -1,17 +1,43 @@
|
||||
import React from "react";
|
||||
const Ctx = React.createContext();
|
||||
export function Tabs({ defaultValue, children }){
|
||||
const [val, setVal] = React.useState(defaultValue);
|
||||
return <Ctx.Provider value={{val,setVal}}>{children}</Ctx.Provider>;
|
||||
}
|
||||
export function TabsList({ className="", ...props }){ return <div className={`inline-flex gap-2 ${className}`} {...props} /> }
|
||||
export function TabsTrigger({ value, children, className="" }){
|
||||
const { val, setVal } = React.useContext(Ctx);
|
||||
const active = val===value;
|
||||
return <button className={`px-3 py-1.5 rounded-xl border ${active?"bg-white":"bg-white/60"} ${className}`} onClick={()=>setVal(value)}>{children}</button>;
|
||||
}
|
||||
export function TabsContent({ value, children, className="" }){
|
||||
const { val } = React.useContext(Ctx);
|
||||
if (val!==value) return null;
|
||||
return <div className={className}>{children}</div>;
|
||||
}
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
import React from "react";
|
||||
export function TooltipProvider({ children }){ return children; }
|
||||
export function Tooltip({ children }){ return <span className="relative">{children}</span>; }
|
||||
export function TooltipTrigger({ asChild, children }){ return children; }
|
||||
export function TooltipContent({ children }){ return <span className="ml-2 text-xs bg-black text-white px-2 py-1 rounded">{children}</span>; }
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
</TooltipPrimitive.Portal>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
|
||||
7380
frontend/package-lock.json
generated
Normal file
7380
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dms-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
@@ -10,24 +10,25 @@
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "15.0.3",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"tailwindcss": "3.4.14",
|
||||
"tailwindcss-animate": "1.0.7",
|
||||
"postcss": "8.4.47",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"autoprefixer": "10.4.20",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"framer-motion": "^11.2.10",
|
||||
"lucide-react": "^0.451.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||
"@radix-ui/react-tooltip": "^1.1.7",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.0"
|
||||
"next": "15.0.3",
|
||||
"postcss": "8.4.47",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss": "3.4.14",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "9.13.0",
|
||||
|
||||
@@ -8,67 +8,84 @@ module.exports = {
|
||||
"./src/**/*.{js,jsx,ts,tsx,mdx}", // เผื่อคุณเก็บ component ใน src
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: { "2xl": "1400px" },
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { 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",
|
||||
},
|
||||
},
|
||||
container: {
|
||||
center: true,
|
||||
padding: '2rem',
|
||||
screens: {
|
||||
'2xl': '1400px'
|
||||
}
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))'
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))'
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))'
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))'
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))'
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))'
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))'
|
||||
},
|
||||
chart: {
|
||||
'1': 'hsl(var(--chart-1))',
|
||||
'2': 'hsl(var(--chart-2))',
|
||||
'3': 'hsl(var(--chart-3))',
|
||||
'4': 'hsl(var(--chart-4))',
|
||||
'5': 'hsl(var(--chart-5))'
|
||||
}
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
from: {
|
||||
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")],
|
||||
};
|
||||
|
||||
26
generate-shadcn-components.yml
Executable file
26
generate-shadcn-components.yml
Executable 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.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
1
|
||||
/var/lib/postgresql/data
|
||||
1758359032
|
||||
1759215497
|
||||
5432
|
||||
/var/run/postgresql
|
||||
*
|
||||
|
||||
Binary file not shown.
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "app",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
Reference in New Issue
Block a user