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