This commit is contained in:
phaichayon
2026-04-23 15:37:01 +07:00
parent 67960174d3
commit a330abf9b6
36 changed files with 4656 additions and 278 deletions

316
API_DOCUMENTATION.md Normal file
View File

@@ -0,0 +1,316 @@
# Elysia API Documentation
## Overview
This project uses ElysiaJS integrated with Next.js App Router to create high-performance, type-safe APIs. The codebase follows a **Feature-based MVC pattern** for maintainability and scalability.
## Base URL
```
http://localhost:3001
```
## Endpoints
### Customers API
#### Get All Customers by Branch
```
GET /api/customers/:branch
```
**Parameters:**
- `branch` (path parameter, required): Branch identifier
- Examples: `branch-01`, `branch-02`, `head-office`
- `status` (query parameter, optional): Filter by customer status
- Values: `active`, `inactive`, `pending`
**Examples:**
1. Get all customers from branch-01:
```bash
curl http://localhost:3001/api/customers/branch-01
```
2. Get active customers from branch-02:
```bash
curl "http://localhost:3001/api/customers/branch-02?status=active"
```
3. Get pending customers from head-office:
```bash
curl "http://localhost:3001/api/customers/head-office?status=pending"
```
**Response Format:**
```json
{
"success": true,
"data": [
{
"id": "cust-001",
"branch": "branch-01",
"name": "สมชาย ใจดี",
"email": "somchai@example.com",
"phone": "081-234-5678",
"company": "บริษัท ไทยธุรกิจ จำกัด",
"address": "123 ถนนสุขุมวิท แขวงคลองตัน เขตคลองเตย กรุงเทพฯ 10110",
"status": "active",
"createdAt": "2024-01-15T09:00:00Z",
"updatedAt": "2024-01-15T09:00:00Z"
}
],
"count": 1,
"message": "Found 1 customer(s) for branch: branch-01"
}
```
#### Get Single Customer by ID
```
GET /api/customers/:branch/:id
```
#### Create Customer
```
POST /api/customers
```
#### Update Customer
```
PUT /api/customers/:branch/:id
```
#### Delete Customer
```
DELETE /api/customers/:branch/:id
```
---
### Quotations API
#### Get All Quotations by Branch
```
GET /api/quotations/:branch
```
**Parameters:**
- `branch` (path parameter, required): Branch identifier
- Examples: `branch-01`, `branch-02`, `head-office`
- `status` (query parameter, optional): Filter by quotation status
- Values: `draft`, `sent`, `accepted`, `rejected`, `expired`
**Examples:**
1. Get all quotations from branch-01:
```bash
curl http://localhost:3001/api/quotations/branch-01
```
2. Get sent quotations from head-office:
```bash
curl "http://localhost:3001/api/quotations/head-office?status=sent"
```
**Response Format:**
```json
{
"success": true,
"data": [
{
"id": "quot-001",
"quotationNumber": "QT-2024-001",
"branch": "branch-01",
"customerId": "cust-001",
"customerName": "สมชาย ใจดี",
"date": "2024-01-20T00:00:00Z",
"validUntil": "2024-02-20T00:00:00Z",
"subtotal": 50000,
"taxRate": 0.07,
"taxAmount": 3500,
"totalAmount": 53500,
"status": "sent",
"notes": "Quotation for office supplies",
"createdAt": "2024-01-20T09:00:00Z",
"updatedAt": "2024-01-20T09:00:00Z"
}
],
"count": 2,
"message": "Found 2 quotation(s) for branch: branch-01"
}
```
#### Get Single Quotation by ID
```
GET /api/quotations/:branch/:id
```
#### Create Quotation
```
POST /api/quotations
```
#### Update Quotation
```
PUT /api/quotations/:branch/:id
```
#### Delete Quotation
```
DELETE /api/quotations/:branch/:id
```
---
## Available Data
### Customers
- `branch-01`: 4 customers (3 active, 1 pending)
- `branch-02`: 3 customers (1 active, 1 inactive, 1 pending)
- `head-office`: 3 customers (all active)
### Quotations
- `branch-01`: 2 quotations (1 sent, 1 accepted)
- `branch-02`: 1 quotation (draft)
- `head-office`: 1 quotation (sent)
## Testing with Browser
Simply open these URLs in your browser:
### Customers
- http://localhost:3001/api/customers/branch-01
- http://localhost:3001/api/customers/branch-02?status=active
- http://localhost:3001/api/customers/head-office
### Quotations
- http://localhost:3001/api/quotations/branch-01
- http://localhost:3001/api/quotations/head-office?status=sent
## Project Structure
This project follows the **Feature-based MVC pattern** as recommended by ElysiaJS:
```
src/
├── app/
│ └── api/
│ └── [[...slugs]]/
│ └── route.ts # Main API entry point
├── modules/
│ ├── customers/
│ │ ├── controller.ts # HTTP handlers & routing
│ │ ├── service.ts # Business logic
│ │ └── model.ts # Schemas & validation
│ └── quotations/
│ ├── controller.ts # HTTP handlers & routing
│ ├── service.ts # Business logic
│ └── model.ts # Schemas & validation
├── types/
│ └── customer.ts # Shared types
├── lib/
│ └── mock-data.ts # Mock data
```
### File Responsibilities
#### Model (`model.ts`)
- Define TypeBox schemas for validation
- Export TypeScript types from schemas
- All data structure definitions
#### Service (`service.ts`)
- Business logic and data operations
- Pure functions (no Elysia dependencies)
- CRUD operations
- Data transformation
#### Controller (`controller.ts`)
- Elysia instance for the module
- Route definitions and handlers
- Request/response validation
- Calls service functions
- HTTP-specific concerns
#### Main Route (`app/api/[[...slugs]]/route.ts`)
- Import all controllers
- Combine with `.use()`
- Export handlers for Next.js
### Important Implementation Notes
This project follows the **correct ElysiaJS + Next.js integration pattern**:
- ✅ Single route file `[[...slugs]]/route.ts` with Elysia internal routing
- ✅ Uses `export const GET = app.fetch` (not `.handle`)
- ✅ Elysia instance has `prefix: '/api'`
- ✅ All routes defined within Elysia instances using `.get()`, `.post()`, etc.
- ✅ WinterCG compliant - works as normal Next.js API route
- ✅ Feature-based MVC pattern for maintainability
- ✅ Clear separation of concerns between Model, View, and Controller
## Technologies Used
- **ElysiaJS**: Type-safe, high-performance web framework
- **Next.js 16**: React framework with App Router
- **TypeScript**: Type safety throughout
- **TypeBox**: Schema validation (via `@elysiajs/schema`)
## Features
✅ Feature-based MVC architecture
✅ Dynamic branch parameter support
✅ Type-safe request/response validation
✅ Optional query parameter filtering
✅ Mock data for customers and quotations
✅ Full TypeScript support
✅ Auto-generated API documentation (Swagger/OpenAPI ready)
✅ Correct ElysiaJS + Next.js integration pattern
✅ Scalable and maintainable code structure
✅ Clear separation of concerns
## Adding New Modules
To add a new module (e.g., `products`):
1. Create folder: `src/modules/products/`
2. Create `model.ts` - Define schemas
3. Create `service.ts` - Business logic
4. Create `controller.ts` - Routes and handlers
5. Update `src/app/api/[[...slugs]]/route.ts`:
```typescript
import { products } from "@/modules/products/controller";
const app = new Elysia({ prefix: "/api" })
.use(customers)
.use(quotations)
.use(products); // Add new module
```

422
KEYCLOAK_AUTH.md Normal file
View File

@@ -0,0 +1,422 @@
# Keycloak Authentication Implementation
## Overview
This document describes the Keycloak (OIDC) authentication implementation integrated into the Next.js + ElysiaJS application.
## Architecture
### Authentication Flow
```
┌─────────────┐ 1. Init ┌──────────────┐ 2. Login ┌──────────┐
│ Browser │ ──────────────> │ Keycloak │ ──────────────> │ Browser │
│ │ │ Server │ │ │
└─────────────┘ └──────────────┘ └──────────┘
│ │
│ 3. Token (JWT) │
├──────────────────────────────────────────────────────────────>│
│ │
│ 4. API Call with Bearer Token │
├─────────────────────────────────────────────┐ │
│ │ │
▼ ▼ │
┌─────────────┐ 5. Verify Token ┌──────────────┐ │
│ Next.js API │ ───────────────────> │ Database │ │
│ (Elysia) │ │ (PostgreSQL) │ │
└─────────────┘ └──────────────┘ │
│ │
│ 6. User Context │
├──────────────────────────────────────────────────────────────────>│
```
## Components
### Backend (ElysiaJS)
#### 1. Database Layer (`src/database/`)
**Files:**
- `src/database/schema/users.ts` - User table schema
- `src/database/db.ts` - Database connection using Drizzle ORM
- `drizzle.config.ts` - Drizzle configuration
**User Schema:**
```typescript
{
id: uuid (primary key)
keycloakId: text (unique, from Keycloak sub)
email: text
name: text
createdAt: timestamp
updatedAt: timestamp
}
```
#### 2. Keycloak Verification (`src/lib/keycloak.ts`)
**Functions:**
- `verifyToken(token)` - Verifies JWT using JWKS from Keycloak
- `extractToken(authHeader)` - Extracts Bearer token from Authorization header
**Features:**
- Automatic JWKS caching
- Token validation (issuer, audience, expiration)
- Type-safe token payload
#### 3. Auth Middleware (`src/middleware/auth.ts`)
**Exports:**
- `authPlugin` - Elysia plugin that validates tokens and attaches user to context
- `requireAuth` - Helper function to require authentication
**Usage:**
```typescript
import { authPlugin } from "@/middleware/auth";
// Apply to all routes
const app = new Elysia().use(authPlugin).get("/protected", ({ user }) => {
return { message: "Hello!", user };
});
```
#### 4. User Service (`src/modules/auth/service.ts`)
**Functions:**
- `findOrCreateUser(payload)` - Finds existing user or creates new one from Keycloak payload
- `getUserByKeycloakId(keycloakId)` - Retrieves user by Keycloak ID
### Frontend (Next.js)
#### 1. Keycloak Client (`src/lib/keycloak-client.ts`)
**Functions:**
- `initKeycloak()` - Initializes Keycloak with `login-required` mode
- `logout()` - Logs out user and clears tokens
- `getUserInfo()` - Returns parsed token payload
- `getToken()` - Returns current access token
- `isAuthenticated()` - Check if user is authenticated
**Features:**
- Memory-only token storage (no localStorage)
- Automatic token refresh (30 seconds before expiry)
- Token refresh on 401 errors
- PKCE flow for security
#### 2. Auth Provider (`src/providers/AuthProvider.tsx`)
**Context:**
```typescript
{
isAuthenticated: boolean;
isLoading: boolean;
userInfo: any;
logout: () => Promise<void>;
}
```
**Hook:**
- `useAuth()` - Access auth context in components
#### 3. API Client (`src/lib/api-client.ts`)
**Enhanced Features:**
- Automatically adds `Authorization: Bearer <token>` header
- Handles 401 errors by triggering token refresh
- Reads token from `window.__KEYCLOAK_TOKEN__`
## Setup Instructions
### 1. Database Setup
```bash
# Copy environment template
cp .env.example .env
# Edit .env with your database credentials
# DATABASE_URL=postgresql://user:password@localhost:5432/allaos
# Generate and run migration
npx drizzle-kit generate
npx drizzle-kit migrate
```
### 2. Keycloak Setup
#### Create a Realm and Client:
1. Log in to Keycloak Admin Console
2. Create a new realm (e.g., `allaos`)
3. Create a new OpenID Connect client:
- Client ID: `allaos-frontend`
- Client Authentication: `ON` (for backend)
- Valid Redirect URIs: `http://localhost:3000/*`
- Web Origins: `http://localhost:3000`
- Access Type: `confidential`
#### Configure Environment Variables:
```env
# Backend (.env)
KEYCLOAK_URL=http://localhost:8080
KEYCLOAK_REALM=allaos
KEYCLOAK_CLIENT_ID=allaos-frontend
KEYCLOAK_CLIENT_SECRET=your-client-secret
# Frontend (.env.local or NEXT_PUBLIC_ in .env)
NEXT_PUBLIC_KEYCLOAK_URL=http://localhost:8080
NEXT_PUBLIC_KEYCLOAK_REALM=allaos
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=allaos-frontend
```
### 3. Install Dependencies
```bash
npm install keycloak jose
npm install -D @types/keycloak-js
```
### 4. Run Application
```bash
npm run dev
```
## Usage Examples
### Protecting API Routes
```typescript
import { Elysia } from "elysia";
import { authPlugin } from "@/middleware/auth";
const app = new Elysia({ prefix: "/api" })
.use(authPlugin)
.get("/protected", ({ user, tokenPayload }) => {
// user is now available from database
// tokenPayload contains Keycloak claims
return {
message: "Protected data",
user: {
id: user.id,
email: user.email,
name: user.name,
},
};
});
```
### Accessing User Info in Components
```typescript
"use client";
import { useAuth } from "@/providers/AuthProvider";
export default function UserProfile() {
const { isAuthenticated, isLoading, userInfo, logout } = useAuth();
if (isLoading) return <div>Loading...</div>;
if (!isAuthenticated) return <div>Not authenticated</div>;
return (
<div>
<h1>Welcome, {userInfo?.name}</h1>
<p>Email: {userInfo?.email}</p>
<button onClick={logout}>Logout</button>
</div>
);
}
```
### Making Authenticated API Calls
```typescript
import { apiClient } from "@/lib/api-client";
// Automatically includes Bearer token
const data = await apiClient("/api/protected-endpoint");
```
## Token Flow
### 1. Initialization
1. User visits application
2. `AuthProvider` initializes Keycloak
3. Keycloak redirects to login page (if not authenticated)
4. User logs in
5. Keycloak redirects back with code
6. Keycloak exchanges code for tokens
7. Access token stored in memory (`window.__KEYCLOAK_TOKEN__`)
### 2. API Calls
1. Component calls `apiClient()`
2. API client reads token from `window.__KEYCLOAK_TOKEN__`
3. Adds `Authorization: Bearer <token>` header
4. Backend receives request, extracts token
5. Verifies token using JWKS
6. Finds/creates user in database
7. Attaches user to request context
8. Route handler processes request
### 3. Token Refresh
1. Background interval checks token every second
2. If token expires in < 30 seconds, refresh automatically
3. If refresh fails, redirect to login
4. On 401 error, trigger immediate refresh attempt
## Security Considerations
### ✅ Implemented
- **Memory-only token storage** - No localStorage/sessionStorage
- **PKCE flow** - Prevents authorization code interception
- **JWT verification** - Using JWKS from Keycloak
- **Token expiration** - Automatic refresh before expiry
- **HTTPS ready** - Works with secure cookies and headers
- **CORS configured** - Only allowed origins
### ⚠️ Additional Recommendations
1. **Enable HTTPS in production**
2. **Set up Keycloak SSL**
3. **Implement rate limiting** on auth endpoints
4. **Add session timeout** on client side
5. **Implement CSRF protection** for state-changing operations
6. **Add audit logging** for authentication events
7. **Enable Keycloak events** for security monitoring
## Testing
### Manual Testing
1. Start Keycloak and your application
2. Visit `http://localhost:3000`
3. You should be redirected to Keycloak login
4. Login with test credentials
5. After login, you should see the application
6. Open browser DevTools → Network
7. Check that API calls have `Authorization: Bearer <token>` header
### Testing Token Expiry
1. Set Keycloak token expiry to 1 minute (for testing)
2. Login to application
3. Wait for token to expire
4. Try making an API call
5. Token should refresh automatically
6. If refresh fails, should redirect to login
### Testing Invalid Token
1. Manually modify `window.__KEYCLOAK_TOKEN__` in DevTools
2. Make an API call
3. Should receive 401 error
4. Should trigger token refresh
## Troubleshooting
### Issue: "Unauthorized: Invalid or expired token"
**Possible causes:**
- Token expired and refresh failed
- Keycloak URL/realm/client ID mismatch
- JWKS endpoint unreachable
**Solutions:**
- Check environment variables
- Verify Keycloak is running
- Check browser console for errors
- Verify JWKS endpoint is accessible
### Issue: User not created in database
**Possible causes:**
- Database connection failed
- Migration not run
- Database permissions issue
**Solutions:**
- Run `npx drizzle-kit migrate`
- Check `DATABASE_URL` in .env
- Verify database is accessible
### Issue: Redirect loop
**Possible causes:**
- Keycloak callback URL not configured
- Client not created or disabled
- Invalid redirect URI
**Solutions:**
- Check Keycloak client settings
- Verify Valid Redirect URIs
- Check client is enabled
## File Structure
```
src/
├── database/
│ ├── db.ts # Database connection
│ └── schema/
│ ├── users.ts # User schema
│ └── index.ts # Schema exports
├── lib/
│ ├── keycloak.ts # JWT verification
│ ├── keycloak-client.ts # Keycloak JS client
│ └── api-client.ts # API client with auth
├── middleware/
│ └── auth.ts # Elysia auth plugin
├── modules/
│ └── auth/
│ └── service.ts # User sync logic
├── providers/
│ └── AuthProvider.tsx # React auth context
└── app/
└── layout.tsx # Root layout with AuthProvider
```
## Next Steps (Phase 2 & 3)
### Phase 2: Role-Based Access Control (RBAC)
- Store user roles in database
- Add role claims to token verification
- Create role-based route protection
- Add admin/role management UI
### Phase 3: Multi-Tenant Support
- Add tenant_id to user schema
- Filter data by tenant
- Add tenant context to requests
- Implement tenant isolation
## References
- [Keycloak Documentation](https://www.keycloak.org/documentation)
- [OpenID Connect Core](https://openid.net/connect/)
- [ElysiaJS Documentation](https://elysiajs.com/)
- [Drizzle ORM Documentation](https://orm.drizzle.team/)
- [Next.js Documentation](https://nextjs.org/docs)

13
drizzle.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import type { Config } from "drizzle-kit";
import { config } from "dotenv";
config({ path: ".env" });
export default {
schema: "./src/database/schema",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL || "",
},
} satisfies Config;

1907
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.4.0", "@base-ui/react": "^1.4.0",
"@elysiajs/eden": "^1.4.9",
"@hugeicons/core-free-icons": "^4.1.1", "@hugeicons/core-free-icons": "^4.1.1",
"@hugeicons/react": "^1.1.6", "@hugeicons/react": "^1.1.6",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
@@ -20,15 +21,22 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"elysia": "^1.4.28",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"jose": "^6.2.2",
"kbar": "^0.1.0-beta.48", "kbar": "^0.1.0-beta.48",
"keycloak": "^1.2.0",
"keycloak-js": "^26.2.4",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"motion": "^12.38.0", "motion": "^12.38.0",
"next": "16.2.3", "next": "16.2.3",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nextjs-toploader": "^3.9.17", "nextjs-toploader": "^3.9.17",
"nuqs": "^2.8.9", "nuqs": "^2.8.9",
"pg": "^8.20.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "19.2.4", "react": "19.2.4",
"react-day-picker": "^9.14.0", "react-day-picker": "^9.14.0",
@@ -47,12 +55,15 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"babel-plugin-react-compiler": "1.0.0", "babel-plugin-react-compiler": "1.0.0",
"drizzle-kit": "^0.31.10",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.3", "eslint-config-next": "16.2.3",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5" "typescript": "^5"
} }
} }

View File

@@ -0,0 +1,28 @@
import PageContainer from "@/components/layout/page-container";
import { buttonVariants } from "@/components/ui/button";
import { Heading } from "@/components/ui/heading";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { IconPlus } from "@tabler/icons-react";
import Link from "next/link";
export default async function Page({ params }) {
const { branch } = await params;
console.log("branch", branch);
return (
<PageContainer>
<div className="flex flex-1 flex-col space-y-4">
<div className="flex items-start justify-between">
<Heading title="ลูกค้า" description="จัดการลูกค้า" />
<Link
href="/dashboard/product/new"
className={cn(buttonVariants(), "text-xs md:text-sm")}
>
<IconPlus className="mr-2 h-4 w-4" />
</Link>
</div>
<Separator />
</div>
</PageContainer>
);
}

View File

@@ -0,0 +1,4 @@
export default async function Page({ params }) {
const { branch } = await params;
return <main>dashboard</main>;
}

View File

@@ -0,0 +1,34 @@
import KBar from "@/components/kbar";
import AppSidebar from "@/components/layout/app-sidebar";
import Header from "@/components/layout/header";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import type { Metadata } from "next";
import { cookies } from "next/headers";
export const metadata: Metadata = {
title: "ALLA-OS",
description: "ALLA-OS",
};
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
// Persisting the sidebar state in the cookie.
const cookieStore = await cookies();
const defaultOpen = cookieStore.get("sidebar_state")?.value === "true";
return (
<KBar>
<SidebarProvider defaultOpen={defaultOpen}>
<AppSidebar />
<SidebarInset>
<Header />
{/* page main content */}
{children}
{/* page main content ends */}
</SidebarInset>
</SidebarProvider>
</KBar>
);
}

View File

@@ -0,0 +1,4 @@
export default async function Page({ params }) {
const { branch } = await params;
return <main>quotations</main>;
}

View File

@@ -1,23 +1,23 @@
import KBar from '@/components/kbar'; import KBar from "@/components/kbar";
import AppSidebar from '@/components/layout/app-sidebar'; import AppSidebar from "@/components/layout/app-sidebar";
import Header from '@/components/layout/header'; import Header from "@/components/layout/header";
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import type { Metadata } from 'next'; import type { Metadata } from "next";
import { cookies } from 'next/headers'; import { cookies } from "next/headers";
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Next Shadcn Dashboard Starter', title: "Admin",
description: 'Basic dashboard with Next.js and Shadcn' description: "Admin",
}; };
export default async function DashboardLayout({ export default async function DashboardLayout({
children children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
// Persisting the sidebar state in the cookie. // Persisting the sidebar state in the cookie.
const cookieStore = await cookies(); const cookieStore = await cookies();
const defaultOpen = cookieStore.get("sidebar_state")?.value === "true" const defaultOpen = cookieStore.get("sidebar_state")?.value === "true";
return ( return (
<KBar> <KBar>
<SidebarProvider defaultOpen={defaultOpen}> <SidebarProvider defaultOpen={defaultOpen}>

View File

@@ -0,0 +1,16 @@
import { Elysia } from "elysia";
import { customers } from "@/modules/customers/controller";
import { quotations } from "@/modules/quotations/controller";
import { auth } from "@/modules/auth/controller";
// Create main Elysia instance with all modules
const app = new Elysia({ prefix: "/api" })
.use(customers) // /api/customers/*
.use(quotations) // /api/quotations/*
.use(auth); // /api/auth/*
// Export handlers for Next.js
export const GET = app.fetch;
export const POST = app.fetch;
export const PUT = app.fetch;
export const DELETE = app.fetch;

View File

@@ -2,6 +2,7 @@ import Providers from "@/components/layout/providers";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { fontVariables } from "@/lib/font"; import { fontVariables } from "@/lib/font";
import ThemeProvider from "@/components/layout/ThemeToggle/theme-provider"; import ThemeProvider from "@/components/layout/ThemeToggle/theme-provider";
import { AuthProvider } from "@/providers/AuthProvider";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
@@ -16,8 +17,8 @@ const META_THEME_COLORS = {
}; };
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Next Shadcn", title: "ALLA-OS",
description: "Basic dashboard with Next.js and Shadcn", description: "ALLA-OS [order-system]",
}; };
export const viewport: Viewport = { export const viewport: Viewport = {
@@ -65,10 +66,12 @@ export default async function RootLayout({
disableTransitionOnChange disableTransitionOnChange
enableColorScheme enableColorScheme
> >
<AuthProvider>
<Providers activeThemeValue={activeThemeValue as string}> <Providers activeThemeValue={activeThemeValue as string}>
<Toaster /> <Toaster />
{children} {children}
</Providers> </Providers>
</AuthProvider>
</ThemeProvider> </ThemeProvider>
</NuqsAdapter> </NuqsAdapter>
</body> </body>

View File

@@ -1,5 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export default async function Page() { export default async function Page() {
redirect("/admin/overview"); redirect("/alla/customers");
} }

View File

@@ -29,7 +29,7 @@ import {
SidebarRail, SidebarRail,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
//import { UserAvatarProfile } from "@/components/user-avatar-profile"; //import { UserAvatarProfile } from "@/components/user-avatar-profile";
import { navItems } from "@/constants/data"; import { navItems, tenantNavConfig } from "@/constants/data";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { import {
@@ -47,31 +47,36 @@ import { usePathname, useRouter } from "next/navigation";
import * as React from "react"; import * as React from "react";
import { Icons } from "../icons"; import { Icons } from "../icons";
import { OrgSwitcher } from "../org-switcher"; import { OrgSwitcher } from "../org-switcher";
import { useAuth } from "@/providers/AuthProvider";
export const company = { export const company = {
name: "Acme Inc", name: "ALLA",
logo: IconPhotoUp, logo: IconPhotoUp,
plan: "Enterprise", plan: "Enterprise",
}; };
const tenants = [ const tenants = [
{ id: "1", name: "Acme Inc" }, { id: "1", name: "ALLA" },
{ id: "2", name: "Beta Corp" }, { id: "2", name: "ONVALLA" },
{ id: "3", name: "Gamma Ltd" },
]; ];
export default function AppSidebar() { export default function AppSidebar() {
const pathname = usePathname(); const pathname = usePathname();
const { isOpen } = useMediaQuery(); const { isOpen } = useMediaQuery();
const router = useRouter(); const router = useRouter();
const handleSwitchTenant = (_tenantId: string) => { const [activeTenant, setActiveTenant] = React.useState(tenants[0]);
// Tenant switching functionality would be implemented here const { isAuthenticated, userInfo, logout } = useAuth();
const handleSwitchTenant = (tenantId: string) => {
const newTenant = tenants.find((t) => t.id === tenantId);
if (newTenant) {
setActiveTenant(newTenant);
// Optional: Redirect to the tenant's dashboard after switching
// router.push(tenantNavConfig[tenantId][0]?.url || "/");
}
}; };
const activeTenant = tenants[0]; // Get navItems based on active tenant
const currentNavItems = tenantNavConfig[activeTenant.id] || navItems;
React.useEffect(() => {
// Side effects based on sidebar state changes
}, [isOpen]);
return ( return (
<Sidebar collapsible="icon"> <Sidebar collapsible="icon">
@@ -86,7 +91,7 @@ export default function AppSidebar() {
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>Overview</SidebarGroupLabel> <SidebarGroupLabel>Overview</SidebarGroupLabel>
<SidebarMenu> <SidebarMenu>
{navItems.map((item) => { {currentNavItems.map((item) => {
const Icon = item.icon ? Icons[item.icon] : Icons.logo; const Icon = item.icon ? Icons[item.icon] : Icons.logo;
return item?.items && item?.items?.length > 0 ? ( return item?.items && item?.items?.length > 0 ? (
<Collapsible <Collapsible
@@ -151,13 +156,18 @@ export default function AppSidebar() {
size="lg" size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
> >
{/* {user && ( {userInfo && (
<UserAvatarProfile <div className="flex items-center gap-2">
className='h-8 w-8 rounded-lg' <IconUserCircle className="h-8 w-8" />
showInfo <div className="flex flex-col text-center">
user={user} <span className="text-sm ">
/> {userInfo?.name ||
)} */} userInfo?.preferred_username ||
"User"}
</span>
</div>
</div>
)}
<IconChevronsDown className="ml-auto size-4" /> <IconChevronsDown className="ml-auto size-4" />
</SidebarMenuButton> </SidebarMenuButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -168,38 +178,33 @@ export default function AppSidebar() {
sideOffset={4} sideOffset={4}
> >
<DropdownMenuLabel className="p-0 font-normal"> <DropdownMenuLabel className="p-0 font-normal">
<div className="px-1 py-1.5"> <div className="flex items-center gap-3 px-1 py-1.5 text-left text-sm">
{/* {user && ( {userInfo && (
<UserAvatarProfile <>
className='h-8 w-8 rounded-lg' <IconUserCircle className="h-8 w-8 rounded-lg bg-muted" />
showInfo <div className="grid flex-1 text-left text-sm leading-tight">
user={user} <span className="truncate font-semibold">
/> {userInfo?.name ||
)} */} userInfo?.preferred_username ||
"User"}
</span>
</div>
</>
)}
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem
onClick={() => router.push("/dashboard/profile")}
>
<IconUserCircle className="mr-2 h-4 w-4" />
Profile
</DropdownMenuItem>
<DropdownMenuItem>
<IconCreditCard className="mr-2 h-4 w-4" />
Billing
</DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem>
<IconBell className="mr-2 h-4 w-4" /> <IconBell className="mr-2 h-4 w-4" />
Notifications Notifications
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem> <DropdownMenuItem onClick={() => logout()}>
<IconLogout className="mr-2 h-4 w-4" /> <IconLogout className="mr-2 h-4 w-4" />
{/* <SignOutButton redirectUrl='/auth/sign-in' /> */} Logout
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -1,27 +1,22 @@
import React from 'react'; import React from "react";
import { SidebarTrigger } from '../ui/sidebar'; import { SidebarTrigger } from "../ui/sidebar";
import { Separator } from '../ui/separator'; import { Separator } from "../ui/separator";
import { Breadcrumbs } from '../breadcrumbs'; import { Breadcrumbs } from "../breadcrumbs";
import SearchInput from '../search-input';
import { UserNav } from './user-nav'; import { UserNav } from "./user-nav";
import { ThemeSelector } from '../theme-selector'; import { ThemeSelector } from "../theme-selector";
import { ModeToggle } from './ThemeToggle/theme-toggle'; import { ModeToggle } from "./ThemeToggle/theme-toggle";
import CtaGithub from './cta-github';
export default function Header() { export default function Header() {
return ( return (
<header className='flex h-16 shrink-0 items-center justify-between gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12'> <header className="flex h-16 shrink-0 items-center justify-between gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className='flex items-center gap-2 px-4'> <div className="flex items-center gap-2 px-4">
<SidebarTrigger className='-ml-1' /> <SidebarTrigger className="-ml-1" />
<Separator orientation='vertical' className='mr-2 h-4' /> <Separator orientation="vertical" className="mr-2 h-4" />
<Breadcrumbs /> <Breadcrumbs />
</div> </div>
<div className='flex items-center gap-2 px-4'> <div className="flex items-center gap-2 px-4">
<CtaGithub />
<div className='hidden md:flex'>
<SearchInput />
</div>
<UserNav /> <UserNav />
<ModeToggle /> <ModeToggle />
<ThemeSelector /> <ThemeSelector />

View File

@@ -1,19 +1,19 @@
'use client'; "use client";
import { Check, ChevronsUpDown, GalleryVerticalEnd } from 'lucide-react'; import { Check, ChevronsUpDown, GalleryVerticalEnd } from "lucide-react";
import * as React from 'react'; import * as React from "react";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from "@/components/ui/dropdown-menu";
import { import {
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem SidebarMenuItem,
} from '@/components/ui/sidebar'; } from "@/components/ui/sidebar";
interface Tenant { interface Tenant {
id: string; id: string;
@@ -23,7 +23,7 @@ interface Tenant {
export function OrgSwitcher({ export function OrgSwitcher({
tenants, tenants,
defaultTenant, defaultTenant,
onTenantSwitch onTenantSwitch,
}: { }: {
tenants: Tenant[]; tenants: Tenant[];
defaultTenant: Tenant; defaultTenant: Tenant;
@@ -49,31 +49,31 @@ export function OrgSwitcher({
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<SidebarMenuButton <SidebarMenuButton
size='lg' size="lg"
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground' className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
> >
<div className='bg-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg'> <div className="bg-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
<GalleryVerticalEnd className='size-4' /> <GalleryVerticalEnd className="size-4" />
</div> </div>
<div className='flex flex-col gap-0.5 leading-none'> <div className="flex flex-col gap-0.5 leading-none">
<span className='font-semibold'>Next Starter</span> <span className="font-semibold">ALLA OS</span>
<span className=''>{selectedTenant.name}</span> <span className="">{selectedTenant.name}</span>
</div> </div>
<ChevronsUpDown className='ml-auto' /> <ChevronsUpDown className="ml-auto" />
</SidebarMenuButton> </SidebarMenuButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
className='w-[--radix-dropdown-menu-trigger-width]' className="w-[--radix-dropdown-menu-trigger-width]"
align='start' align="start"
> >
{tenants.map((tenant) => ( {tenants.map((tenant) => (
<DropdownMenuItem <DropdownMenuItem
key={tenant.id} key={tenant.id}
onSelect={() => handleTenantSwitch(tenant)} onSelect={() => handleTenantSwitch(tenant)}
> >
{tenant.name}{' '} {tenant.name}{" "}
{tenant.id === selectedTenant.id && ( {tenant.id === selectedTenant.id && (
<Check className='ml-auto' /> <Check className="ml-auto" />
)} )}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}

View File

@@ -1,4 +1,4 @@
import { NavGroup } from '@/types'; import { NavGroup } from "@/types";
/** /**
* Navigation configuration with RBAC support * Navigation configuration with RBAC support
@@ -35,163 +35,163 @@ import { NavGroup } from '@/types';
*/ */
export const navGroups: NavGroup[] = [ export const navGroups: NavGroup[] = [
{ {
label: 'Overview', label: "Overview",
items: [ items: [
{ {
title: 'Dashboard', title: "Dashboard",
url: '/dashboard/overview', url: "/dashboard/overview",
icon: 'dashboard', icon: "dashboard",
isActive: false, isActive: false,
shortcut: ['d', 'd'], shortcut: ["d", "d"],
items: [] items: [],
}, },
{ {
title: 'Workspaces', title: "Workspaces",
url: '/dashboard/workspaces', url: "/dashboard/workspaces",
icon: 'workspace', icon: "workspace",
isActive: false,
items: []
},
{
title: 'Teams',
url: '/dashboard/workspaces/team',
icon: 'teams',
isActive: false, isActive: false,
items: [], items: [],
access: { requireOrg: true }
}, },
{ {
title: 'Product', title: "Teams",
url: '/dashboard/product', url: "/dashboard/workspaces/team",
icon: 'product', icon: "teams",
shortcut: ['p', 'p'],
isActive: false, isActive: false,
items: [] items: [],
access: { requireOrg: true },
}, },
{ {
title: 'Users', title: "Product",
url: '/dashboard/users', url: "/dashboard/product",
icon: 'teams', icon: "product",
shortcut: ['u', 'u'], shortcut: ["p", "p"],
isActive: false, isActive: false,
items: [] items: [],
}, },
{ {
title: 'Kanban', title: "Users",
url: '/dashboard/kanban', url: "/dashboard/users",
icon: 'kanban', icon: "teams",
shortcut: ['k', 'k'], shortcut: ["u", "u"],
isActive: false, isActive: false,
items: [] items: [],
}, },
{ {
title: 'Chat', title: "Kanban",
url: '/dashboard/chat', url: "/dashboard/kanban",
icon: 'chat', icon: "kanban",
shortcut: ['c', 'c'], shortcut: ["k", "k"],
isActive: false, isActive: false,
items: [] items: [],
}
]
}, },
{ {
label: 'Elements', title: "Chat",
url: "/dashboard/chat",
icon: "chat",
shortcut: ["c", "c"],
isActive: false,
items: [],
},
],
},
{
label: "Elements",
items: [ items: [
{ {
title: 'Forms', title: "Forms",
url: '#', url: "#",
icon: 'forms', icon: "forms",
isActive: true, isActive: true,
items: [ items: [
{ {
title: 'Basic Form', title: "Basic Form",
url: '/dashboard/forms/basic', url: "/dashboard/forms/basic",
icon: 'forms', icon: "forms",
shortcut: ['f', 'f'] shortcut: ["f", "f"],
}, },
{ {
title: 'Multi-Step Form', title: "Multi-Step Form",
url: '/dashboard/forms/multi-step', url: "/dashboard/forms/multi-step",
icon: 'forms' icon: "forms",
}, },
{ {
title: 'Sheet & Dialog', title: "Sheet & Dialog",
url: '/dashboard/forms/sheet-form', url: "/dashboard/forms/sheet-form",
icon: 'forms' icon: "forms",
}, },
{ {
title: 'Advanced Patterns', title: "Advanced Patterns",
url: '/dashboard/forms/advanced', url: "/dashboard/forms/advanced",
icon: 'forms' icon: "forms",
} },
] ],
}, },
{ {
title: 'React Query', title: "React Query",
url: '/dashboard/react-query', url: "/dashboard/react-query",
icon: 'code', icon: "code",
isActive: false, isActive: false,
items: [] items: [],
}, },
{ {
title: 'Icons', title: "Icons",
url: '/dashboard/elements/icons', url: "/dashboard/elements/icons",
icon: 'palette', icon: "palette",
isActive: false, isActive: false,
items: [] items: [],
} },
] ],
}, },
{ {
label: '', label: "",
items: [ items: [
{ {
title: 'Pro', title: "Pro",
url: '#', url: "#",
icon: 'pro', icon: "pro",
isActive: true, isActive: true,
items: [ items: [
{ {
title: 'Exclusive', title: "Exclusive",
url: '/dashboard/exclusive', url: "/dashboard/exclusive",
icon: 'exclusive', icon: "exclusive",
shortcut: ['e', 'e'] shortcut: ["e", "e"],
} },
] ],
}, },
{ {
title: 'Account', title: "Account",
url: '#', url: "#",
icon: 'account', icon: "account",
isActive: true, isActive: true,
items: [ items: [
{ {
title: 'Profile', title: "Profile",
url: '/dashboard/profile', url: "/dashboard/profile",
icon: 'profile', icon: "profile",
shortcut: ['m', 'm'] shortcut: ["m", "m"],
}, },
{ {
title: 'Notifications', title: "Notifications",
url: '/dashboard/notifications', url: "/dashboard/notifications",
icon: 'notification', icon: "notification",
shortcut: ['n', 'n'] shortcut: ["n", "n"],
}, },
{ {
title: 'Billing', title: "Billing",
url: '/dashboard/billing', url: "/dashboard/billing",
icon: 'billing', icon: "billing",
shortcut: ['b', 'b'], shortcut: ["b", "b"],
access: { requireOrg: true } access: { requireOrg: true },
}, },
{ {
title: 'Login', title: "Login",
shortcut: ['l', 'l'], shortcut: ["l", "l"],
url: '/', url: "/",
icon: 'login' icon: "login",
} },
] ],
} },
] ],
} },
]; ];

View File

@@ -1,4 +1,42 @@
import { NavItem } from '@/types'; import { NavItem } from "@/types";
// Tenant-specific navigation configurations
export const tenantNavConfig: Record<string, NavItem[]> = {
"1": [
// ALLA tenant
{
title: "Dashboard",
url: "/alla/dashboard/overview",
icon: "dashboard",
isActive: false,
items: [],
},
{
title: "Customers",
url: "/alla/customers",
icon: "product",
isActive: false,
items: [],
},
],
"2": [
// ONVALLA tenant
{
title: "Dashboard",
url: "/onvilla/dashboard/overview",
icon: "dashboard",
isActive: false,
items: [],
},
{
title: "Customers",
url: "/onvilla/customers",
icon: "product",
isActive: false,
items: [],
},
],
};
export type Product = { export type Product = {
photo_url: string; photo_url: string;
@@ -14,50 +52,19 @@ export type Product = {
//Info: The following data is used for the sidebar navigation and Cmd K bar. //Info: The following data is used for the sidebar navigation and Cmd K bar.
export const navItems: NavItem[] = [ export const navItems: NavItem[] = [
{ {
title: 'Dashboard', title: "Dashboard",
url: '/dashboard/overview', url: "/alla/dashboard/overview",
icon: 'dashboard', icon: "dashboard",
isActive: false, isActive: false,
shortcut: ['d', 'd'], items: [], // Empty array as there are no child items for Dashboard
items: [] // Empty array as there are no child items for Dashboard
}, },
{ {
title: 'Product', title: "Customers",
url: '/dashboard/product', url: "/alla/customers",
icon: 'product', icon: "product",
shortcut: ['p', 'p'],
isActive: false, isActive: false,
items: [] // No child items items: [], // No child items
}, },
{
title: 'Account',
url: '#', // Placeholder as there is no direct link for the parent
icon: 'billing',
isActive: true,
items: [
{
title: 'Profile',
url: '/dashboard/profile',
icon: 'userPen',
shortcut: ['m', 'm']
},
{
title: 'Login',
shortcut: ['l', 'l'],
url: '/',
icon: 'login'
}
]
},
{
title: 'Kanban',
url: '/dashboard/kanban',
icon: 'kanban',
shortcut: ['k', 'k'],
isActive: false,
items: [] // No child items
}
]; ];
export interface SaleUser { export interface SaleUser {
@@ -72,42 +79,42 @@ export interface SaleUser {
export const recentSalesData: SaleUser[] = [ export const recentSalesData: SaleUser[] = [
{ {
id: 1, id: 1,
name: 'Olivia Martin', name: "Olivia Martin",
email: 'olivia.martin@email.com', email: "olivia.martin@email.com",
amount: '+$1,999.00', amount: "+$1,999.00",
image: 'https://api.slingacademy.com/public/sample-users/1.png', image: "https://api.slingacademy.com/public/sample-users/1.png",
initials: 'OM' initials: "OM",
}, },
{ {
id: 2, id: 2,
name: 'Jackson Lee', name: "Jackson Lee",
email: 'jackson.lee@email.com', email: "jackson.lee@email.com",
amount: '+$39.00', amount: "+$39.00",
image: 'https://api.slingacademy.com/public/sample-users/2.png', image: "https://api.slingacademy.com/public/sample-users/2.png",
initials: 'JL' initials: "JL",
}, },
{ {
id: 3, id: 3,
name: 'Isabella Nguyen', name: "Isabella Nguyen",
email: 'isabella.nguyen@email.com', email: "isabella.nguyen@email.com",
amount: '+$299.00', amount: "+$299.00",
image: 'https://api.slingacademy.com/public/sample-users/3.png', image: "https://api.slingacademy.com/public/sample-users/3.png",
initials: 'IN' initials: "IN",
}, },
{ {
id: 4, id: 4,
name: 'William Kim', name: "William Kim",
email: 'will@email.com', email: "will@email.com",
amount: '+$99.00', amount: "+$99.00",
image: 'https://api.slingacademy.com/public/sample-users/4.png', image: "https://api.slingacademy.com/public/sample-users/4.png",
initials: 'WK' initials: "WK",
}, },
{ {
id: 5, id: 5,
name: 'Sofia Davis', name: "Sofia Davis",
email: 'sofia.davis@email.com', email: "sofia.davis@email.com",
amount: '+$39.00', amount: "+$39.00",
image: 'https://api.slingacademy.com/public/sample-users/5.png', image: "https://api.slingacademy.com/public/sample-users/5.png",
initials: 'SD' initials: "SD",
} },
]; ];

9
src/database/db.ts Normal file
View File

@@ -0,0 +1,9 @@
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export const db = drizzle(pool, { schema });

View File

@@ -0,0 +1 @@
export * from "./users";

View File

@@ -0,0 +1,13 @@
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
keycloakId: text("keycloak_id").notNull().unique(),
email: text("email").notNull(),
name: text("name").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;

View File

@@ -1,12 +1,36 @@
const BASE_URL = '/api'; const BASE_URL = "/api";
export async function apiClient<T>(
endpoint: string,
options?: RequestInit,
): Promise<T> {
// Get token from global window object (set by Keycloak client)
const token =
typeof window !== "undefined" ? (window as any).__KEYCLOAK_TOKEN__ : null;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...((options?.headers as Record<string, string>) || {}),
};
// Add Authorization header if token exists
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
export async function apiClient<T>(endpoint: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${endpoint}`, { const res = await fetch(`${BASE_URL}${endpoint}`, {
headers: { 'Content-Type': 'application/json' }, ...options,
...options headers,
}); });
if (!res.ok) { if (!res.ok) {
// Handle 401 - token expired
if (res.status === 401) {
// Trigger token refresh by dispatching event
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("token-expired"));
}
}
throw new Error(`API error: ${res.status} ${res.statusText}`); throw new Error(`API error: ${res.status} ${res.statusText}`);
} }

143
src/lib/keycloak-client.ts Normal file
View File

@@ -0,0 +1,143 @@
import Keycloak from "keycloak-js";
const KEYCLOAK_URL =
process.env.NEXT_PUBLIC_KEYCLOAK_URL || "http://localhost:8080";
const KEYCLOAK_REALM = process.env.NEXT_PUBLIC_KEYCLOAK_REALM || "allaos";
const KEYCLOAK_CLIENT_ID =
process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID || "allaos-frontend";
// Initialize Keycloak instance
const keycloak = new Keycloak({
url: KEYCLOAK_URL,
realm: KEYCLOAK_REALM,
clientId: KEYCLOAK_CLIENT_ID,
});
// Token refresh interval (in seconds)
const MIN_TOKEN_VALIDITY = 30; // Refresh 30 seconds before expiry
/**
* Initialize Keycloak and authenticate user
*/
export async function initKeycloak(): Promise<boolean> {
try {
const authenticated = await keycloak.init({
onLoad: "login-required",
checkLoginIframe: false,
pkceMethod: "S256",
});
if (authenticated) {
// Store token in window object for API client
updateGlobalToken();
// Start token refresh timer
startTokenRefresh();
console.log("User authenticated:", keycloak.tokenParsed);
}
return authenticated;
} catch (error) {
console.error("Keycloak initialization failed:", error);
return false;
}
}
/**
* Update global window object with current token
*/
function updateGlobalToken() {
if (typeof window !== "undefined" && keycloak.token) {
(window as any).__KEYCLOAK_TOKEN__ = keycloak.token;
}
}
/**
* Start automatic token refresh
*/
function startTokenRefresh() {
// Clear existing interval if any
if ((window as any).__TOKEN_REFRESH_INTERVAL__) {
clearInterval((window as any).__TOKEN_REFRESH_INTERVAL__);
}
// Set up refresh interval
(window as any).__TOKEN_REFRESH_INTERVAL__ = setInterval(async () => {
try {
const refreshed = await keycloak.updateToken(MIN_TOKEN_VALIDITY);
if (refreshed) {
console.log("Token refreshed");
updateGlobalToken();
}
} catch (error) {
console.error("Failed to refresh token:", error);
// Redirect to login on refresh failure
await keycloak.login();
}
}, 1000); // Check every second
}
/**
* Logout user
*/
export async function logout() {
try {
// Clear refresh interval
if ((window as any).__TOKEN_REFRESH_INTERVAL__) {
clearInterval((window as any).__TOKEN_REFRESH_INTERVAL__);
}
// Clear global token
if (typeof window !== "undefined") {
delete (window as any).__KEYCLOAK_TOKEN__;
}
await keycloak.logout({ redirectUri: window.location.origin });
} catch (error) {
console.error("Logout failed:", error);
}
}
/**
* Get current user info
*/
export function getUserInfo() {
return keycloak.tokenParsed;
}
/**
* Get current token
*/
export function getToken() {
return keycloak.token;
}
/**
* Check if user is authenticated
*/
export function isAuthenticated(): boolean {
return !!keycloak.authenticated;
}
// Listen for token expired events from API client
if (typeof window !== "undefined") {
window.addEventListener("token-expired", async () => {
console.log("Token expired, attempting refresh...");
try {
const refreshed = await keycloak.updateToken(MIN_TOKEN_VALIDITY);
if (refreshed) {
updateGlobalToken();
console.log("Token refreshed after expiry");
} else {
console.log("Could not refresh token, redirecting to login");
await keycloak.login();
}
} catch (error) {
console.error("Failed to refresh expired token:", error);
await keycloak.login();
}
});
}
export default keycloak;

59
src/lib/keycloak.ts Normal file
View File

@@ -0,0 +1,59 @@
import { jwtVerify, createRemoteJWKSet } from "jose";
const KEYCLOAK_URL = process.env.KEYCLOAK_URL || "http://localhost:8080";
const KEYCLOAK_REALM = process.env.KEYCLOAK_REALM || "allaos";
// JWKS endpoint for verifying tokens
const JWKS_URL = `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/certs`;
// Create JWKS cache
const JWKS = createRemoteJWKSet(new URL(JWKS_URL));
export interface KeycloakTokenPayload {
sub: string; // User ID
email?: string;
name?: string;
preferred_username?: string;
exp: number;
iat: number;
iss: string;
aud: string;
}
/**
* Verify a Keycloak JWT access token
* @param token The JWT token string
* @returns Decoded token payload
* @throws Error if token is invalid
*/
export async function verifyToken(
token: string,
): Promise<KeycloakTokenPayload> {
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}`,
audience: process.env.KEYCLOAK_CLIENT_ID,
});
return payload as KeycloakTokenPayload;
} catch (error) {
console.error("Token verification failed:", error);
throw new Error("Invalid or expired token");
}
}
/**
* Extract Bearer token from Authorization header
* @param authHeader The Authorization header value
* @returns The token string or null
*/
export function extractToken(authHeader: string | null): string | null {
if (!authHeader) return null;
const parts = authHeader.split(" ");
if (parts.length !== 2 || parts[0] !== "Bearer") {
return null;
}
return parts[1];
}

132
src/lib/mock-data.ts Normal file
View File

@@ -0,0 +1,132 @@
import { Customer } from "@/types/customer";
export const mockCustomers: Customer[] = [
{
id: "cust-001",
branch: "branch-01",
name: "สมชาย ใจดี",
email: "somchai@example.com",
phone: "081-234-5678",
company: "บริษัท ไทยธุรกิจ จำกัด",
address: "123 ถนนสุขุมวิท แขวงคลองตัน เขตคลองเตย กรุงเทพฯ 10110",
status: "active",
createdAt: "2024-01-15T09:00:00Z",
updatedAt: "2024-01-15T09:00:00Z",
},
{
id: "cust-002",
branch: "branch-01",
name: "วิภา สุขสันต์",
email: "wipa@example.com",
phone: "082-345-6789",
company: "บริษัท นวัตกรรมไทย จำกัด",
address: "456 ถนนพระราม 4 แขวงคลองเตย เขตคลองเตย กรุงเทพฯ 10110",
status: "active",
createdAt: "2024-02-20T10:30:00Z",
updatedAt: "2024-02-20T10:30:00Z",
},
{
id: "cust-003",
branch: "branch-01",
name: "อนุชิต กล้าหาญ",
email: "anuchit@example.com",
phone: "083-456-7890",
company: "บริษัท พัฒนาธุรกิจ จำกัด",
address: "789 ถนนสีลม แขวงสีลม เขตบางรัก กรุงเทพฯ 10500",
status: "active",
createdAt: "2024-03-10T14:15:00Z",
updatedAt: "2024-03-10T14:15:00Z",
},
{
id: "cust-004",
branch: "branch-02",
name: "มานี มีสุข",
email: "manee@example.com",
phone: "084-567-8901",
company: "บริษัท ค้าส่งสินค้า จำกัด",
address: "321 ถนนจรัญสนิทวงศ์ แขวงบางพลัด เขตบางพลัด กรุงเทพฯ 10700",
status: "active",
createdAt: "2024-01-25T11:00:00Z",
updatedAt: "2024-01-25T11:00:00Z",
},
{
id: "cust-005",
branch: "branch-02",
name: "ประยุทธ์ จริงใจ",
email: "prayut@example.com",
phone: "085-678-9012",
company: "บริษัท อิเล็กทรอนิกส์ ไทย จำกัด",
address: "654 ถนนเพชรบุรี แขวงทุ่งพญาไท เขตราชเทวี กรุงเทพฯ 10400",
status: "inactive",
createdAt: "2024-02-05T08:45:00Z",
updatedAt: "2024-04-01T10:00:00Z",
},
{
id: "cust-006",
branch: "branch-02",
name: "สมหญิง แก้วสะอาด",
email: "somying@example.com",
phone: "086-789-0123",
company: "บริษัท อาหารแห้ง จำกัด",
address: "987 ถนนลาดพร้าว แขวงลาดพร้าว เขตลาดพร้าว กรุงเทพฯ 10230",
status: "pending",
createdAt: "2024-04-15T16:20:00Z",
updatedAt: "2024-04-15T16:20:00Z",
},
{
id: "cust-007",
branch: "head-office",
name: "ภูมิ รักษ์โลก",
email: "phumi@example.com",
phone: "087-890-1234",
company: "บริษัท เคมีภัณฑ์ ไทย จำกัด",
address: "147 ถนนวิภาวดีรังสิต แขวงดอนเมือง เขตดอนเมือง กรุงเทพฯ 10210",
status: "active",
createdAt: "2024-01-10T09:30:00Z",
updatedAt: "2024-01-10T09:30:00Z",
},
{
id: "cust-008",
branch: "head-office",
name: "กัญญา มีเมตตา",
email: "kanya@example.com",
phone: "088-901-2345",
company: "บริษัท สิ่งทอ ไทย จำกัด",
address: "258 ถนนพหลโยธิน แขวงสามเสนใน เขตพญาไท กรุงเทพฯ 10400",
status: "active",
createdAt: "2024-03-20T13:00:00Z",
updatedAt: "2024-03-20T13:00:00Z",
},
{
id: "cust-009",
branch: "head-office",
name: "สุเมธ รัตนา",
email: "sumet@example.com",
phone: "089-012-3456",
company: "บริษัท ก่อสร้าง รุ่งเรือง จำกัด",
address: "369 ถนนนิมิตรใหม่ แขวงบางบอน เขตบางบอน กรุงเทพฯ 10150",
status: "active",
createdAt: "2024-02-28T15:45:00Z",
updatedAt: "2024-02-28T15:45:00Z",
},
{
id: "cust-010",
branch: "branch-01",
name: "นภา รัตนา",
email: "napha@example.com",
phone: "090-123-4567",
company: "บริษัท ซอฟต์แวร์ ไทย จำกัด",
address: "741 ถนนเทพรัตน แขวงบางนา เขตบางนา กรุงเทพฯ 10260",
status: "pending",
createdAt: "2024-04-10T11:30:00Z",
updatedAt: "2024-04-10T11:30:00Z",
},
];
export const getCustomersByBranch = (branch: string): Customer[] => {
return mockCustomers.filter((customer) => customer.branch === branch);
};
export const getCustomerById = (id: string): Customer | undefined => {
return mockCustomers.find((customer) => customer.id === id);
};

65
src/middleware/auth.ts Normal file
View File

@@ -0,0 +1,65 @@
import { Elysia, type Context } from "elysia";
import { verifyToken, extractToken } from "@/lib/keycloak";
import { findOrCreateUser } from "@/modules/auth/service";
import type { KeycloakTokenPayload } from "@/lib/keycloak";
import type { User } from "@/database/schema";
// Extend Elysia context to include user
declare module "elysia" {
interface Context {
user?: User;
tokenPayload?: KeycloakTokenPayload;
}
}
/**
* Elysia plugin for Keycloak authentication
* Validates Bearer token and attaches user to context
*/
export const authPlugin = new Elysia({ name: "auth" }).derive(
async ({ request, set }) => {
const authHeader = request.headers.get("Authorization");
const token = extractToken(authHeader);
if (!token) {
set.status = 401;
return {
error: "Unauthorized: No token provided",
user: undefined,
tokenPayload: undefined,
};
}
try {
// Verify token
const payload = await verifyToken(token);
// Find or create user
const user = await findOrCreateUser(payload);
return {
user,
tokenPayload: payload,
};
} catch (error) {
console.error("Authentication failed:", error);
set.status = 401;
return {
error: "Unauthorized: Invalid or expired token",
user: undefined,
tokenPayload: undefined,
};
}
},
);
/**
* Helper to require authentication on a route
* Returns 401 if no valid user
*/
export const requireAuth = (context: Context) => {
if (!context.user) {
throw new Error("Unauthorized");
}
return context;
};

View File

@@ -0,0 +1,64 @@
import { Elysia, t } from "elysia";
import { authPlugin } from "@/middleware/auth";
import type { User } from "@/database/schema";
import type { KeycloakTokenPayload } from "@/lib/keycloak";
export const auth = new Elysia({ prefix: "/auth", tags: ["auth"] })
.use(authPlugin)
// GET /api/auth/me - Get current user info
.get(
"/me",
(context: any) => {
const user = context.user as User;
const tokenPayload = context.tokenPayload as KeycloakTokenPayload;
if (!user || !tokenPayload) {
throw new Error("Unauthorized");
}
return {
success: true as const,
data: {
user: {
id: user.id,
keycloakId: user.keycloakId,
email: user.email,
name: user.name,
createdAt: user.createdAt.toISOString(),
},
tokenInfo: {
sub: tokenPayload.sub,
email: tokenPayload.email,
name: tokenPayload.name,
exp: tokenPayload.exp,
iat: tokenPayload.iat,
},
},
};
},
{
response: t.Object({
success: t.Literal(true),
data: t.Object({
user: t.Object({
id: t.String(),
keycloakId: t.String(),
email: t.String(),
name: t.String(),
createdAt: t.String(),
}),
tokenInfo: t.Object({
sub: t.String(),
email: t.Optional(t.String()),
name: t.Optional(t.String()),
exp: t.Number(),
iat: t.Number(),
}),
}),
}),
detail: {
description: "Get current authenticated user information",
security: [{ Bearer: [] }],
},
},
);

View File

@@ -0,0 +1,47 @@
import { db } from "@/database/db";
import { users } from "@/database/schema";
import { eq } from "drizzle-orm";
import type { KeycloakTokenPayload } from "@/lib/keycloak";
/**
* Find or create user based on Keycloak token payload
* @param payload Keycloak token payload
* @returns User record from database
*/
export async function findOrCreateUser(payload: KeycloakTokenPayload) {
// Try to find existing user by keycloakId
const existingUser = await db
.select()
.from(users)
.where(eq(users.keycloakId, payload.sub))
.limit(1);
if (existingUser.length > 0) {
return existingUser[0];
}
// Create new user if not found
const newUser = {
keycloakId: payload.sub,
email: payload.email || "",
name: payload.name || payload.preferred_username || "Unknown User",
};
const [createdUser] = await db.insert(users).values(newUser).returning();
return createdUser;
}
/**
* Get user by Keycloak ID
* @param keycloakId The Keycloak user ID
* @returns User record or null
*/
export async function getUserByKeycloakId(keycloakId: string) {
const result = await db
.select()
.from(users)
.where(eq(users.keycloakId, keycloakId))
.limit(1);
return result[0] || null;
}

View File

@@ -0,0 +1,216 @@
import { Elysia, t } from "elysia";
import * as service from "./service";
import { CustomerModel } from "./model";
// Create Elysia instance for customers module
export const customers = new Elysia({
prefix: "/customers",
tags: ["customers"],
})
.model(CustomerModel)
// GET /api/customers/:branch - Get all customers by branch
.get(
"/:branch",
({ params, query }) => {
const { branch } = params;
const { status } = query as { status?: string };
const customers = service.getAllCustomers(
branch,
status as "active" | "inactive" | "pending" | undefined,
);
return {
success: true,
data: customers,
count: customers.length,
message: `Found ${customers.length} customer(s) for branch: ${branch}`,
};
},
{
params: t.Object({
branch: t.String(),
}),
query: t.Optional(
t.Object({
status: t.Optional(
t.Union([
t.Literal("active"),
t.Literal("inactive"),
t.Literal("pending"),
]),
),
}),
),
response: CustomerModel.CustomerList,
detail: {
description: "Get all customers for a specific branch",
parameters: [
{
name: "branch",
in: "path",
required: true,
schema: { type: "string" },
description:
"Branch identifier (e.g., branch-01, branch-02, head-office)",
},
{
name: "status",
in: "query",
required: false,
schema: {
type: "string",
enum: ["active", "inactive", "pending"],
},
description: "Filter customers by status",
},
],
},
},
)
// GET /api/customers/:branch/:id - Get single customer by ID
.get(
"/:branch/:id",
({ params }) => {
const { branch, id } = params;
const customer = service.getCustomerByIdAndBranch(branch, id);
if (!customer) {
return {
success: false,
error: "Customer not found",
};
}
return {
success: true,
data: customer,
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: CustomerModel.Customer,
}),
t.Object({
success: t.Literal(false),
error: t.String(),
}),
]),
detail: {
description: "Get a single customer by ID and branch",
},
},
)
// POST /api/customers - Create new customer
.post(
"/",
({ body }) => {
const customer = service.createCustomer(body);
return {
success: true,
data: customer,
message: "Customer created successfully",
};
},
{
body: CustomerModel.CreateCustomer,
response: t.Object({
success: t.Boolean(),
data: CustomerModel.Customer,
message: t.String(),
}),
detail: {
description: "Create a new customer",
},
},
)
// PUT /api/customers/:branch/:id - Update customer
.put(
"/:branch/:id",
({ params, body }) => {
const { branch, id } = params;
const customer = service.updateCustomer(branch, id, body);
if (!customer) {
return {
success: false,
error: "Customer not found",
};
}
return {
success: true,
data: customer,
message: "Customer updated successfully",
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
}),
body: CustomerModel.UpdateCustomer,
response: t.Union([
t.Object({
success: t.Literal(true),
data: CustomerModel.Customer,
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
}),
]),
detail: {
description: "Update an existing customer",
},
},
)
// DELETE /api/customers/:branch/:id - Delete customer
.delete(
"/:branch/:id",
({ params }) => {
const { branch, id } = params;
const customer = service.deleteCustomer(branch, id);
if (!customer) {
return {
success: false,
error: "Customer not found",
};
}
return {
success: true,
data: customer,
message: "Customer deleted successfully",
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: CustomerModel.Customer,
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
}),
]),
detail: {
description: "Delete a customer",
},
},
);

View File

@@ -0,0 +1,82 @@
import { t } from "elysia";
// Schemas for validation
export const CustomerModel = {
Customer: t.Object({
id: t.String(),
branch: t.String(),
name: t.String(),
email: t.String({ format: "email" }),
phone: t.String(),
company: t.String(),
address: t.String(),
status: t.Union([
t.Literal("active"),
t.Literal("inactive"),
t.Literal("pending"),
]),
createdAt: t.String({ format: "date-time" }),
updatedAt: t.String({ format: "date-time" }),
}),
CreateCustomer: t.Object({
branch: t.String(),
name: t.String(),
email: t.String({ format: "email" }),
phone: t.String(),
company: t.String(),
address: t.String(),
status: t.Optional(
t.Union([
t.Literal("active"),
t.Literal("inactive"),
t.Literal("pending"),
]),
),
}),
UpdateCustomer: t.Object({
name: t.Optional(t.String()),
email: t.Optional(t.String({ format: "email" })),
phone: t.Optional(t.String()),
company: t.Optional(t.String()),
address: t.Optional(t.String()),
status: t.Optional(
t.Union([
t.Literal("active"),
t.Literal("inactive"),
t.Literal("pending"),
]),
),
}),
CustomerList: t.Object({
success: t.Boolean(),
data: t.Array(
t.Object({
id: t.String(),
branch: t.String(),
name: t.String(),
email: t.String(),
phone: t.String(),
company: t.String(),
address: t.String(),
status: t.Union([
t.Literal("active"),
t.Literal("inactive"),
t.Literal("pending"),
]),
createdAt: t.String(),
updatedAt: t.String(),
}),
),
count: t.Number(),
message: t.Optional(t.String()),
}),
};
// Export types from schemas
export type Customer = typeof CustomerModel.Customer.static;
export type CreateCustomer = typeof CustomerModel.CreateCustomer.static;
export type UpdateCustomer = typeof CustomerModel.UpdateCustomer.static;
export type CustomerList = typeof CustomerModel.CustomerList.static;

View File

@@ -0,0 +1,109 @@
import { getCustomersByBranch, getCustomerById } from "@/lib/mock-data";
import type { Customer, CreateCustomer, UpdateCustomer } from "./model";
/**
* Get all customers for a specific branch
* @param branch - Branch identifier
* @param status - Optional status filter
* @returns Array of customers
*/
export function getAllCustomers(
branch: string,
status?: "active" | "inactive" | "pending",
): Customer[] {
let customers = getCustomersByBranch(branch);
if (status) {
customers = customers.filter((customer) => customer.status === status);
}
return customers;
}
/**
* Get a single customer by ID and branch
* @param branch - Branch identifier
* @param id - Customer ID
* @returns Customer or undefined if not found
*/
export function getCustomerByIdAndBranch(
branch: string,
id: string,
): Customer | undefined {
const customer = getCustomerById(id);
// Only return if customer belongs to the specified branch
if (customer && customer.branch === branch) {
return customer;
}
return undefined;
}
/**
* Create a new customer
* @param data - Customer creation data
* @returns Newly created customer
*/
export function createCustomer(data: CreateCustomer): Customer {
const newCustomer: Customer = {
id: `cust-${Date.now()}`,
...data,
status: data.status || "active",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// In a real app, this would save to database
// For now, we'll just return the new customer
return newCustomer;
}
/**
* Update an existing customer
* @param branch - Branch identifier
* @param id - Customer ID
* @param data - Customer update data
* @returns Updated customer or undefined if not found
*/
export function updateCustomer(
branch: string,
id: string,
data: UpdateCustomer,
): Customer | undefined {
const customer = getCustomerByIdAndBranch(branch, id);
if (!customer) {
return undefined;
}
// Merge update data
const updatedCustomer: Customer = {
...customer,
...data,
updatedAt: new Date().toISOString(),
};
// In a real app, this would update database
return updatedCustomer;
}
/**
* Delete a customer
* @param branch - Branch identifier
* @param id - Customer ID
* @returns Deleted customer or undefined if not found
*/
export function deleteCustomer(
branch: string,
id: string,
): Customer | undefined {
const customer = getCustomerByIdAndBranch(branch, id);
if (!customer) {
return undefined;
}
// In a real app, this would delete from database
return customer;
}

View File

@@ -0,0 +1,224 @@
import { Elysia, t } from "elysia";
import * as service from "./service";
import { QuotationModel } from "./model";
// Create Elysia instance for quotations module
export const quotations = new Elysia({
prefix: "/quotations",
tags: ["quotations"],
})
.model(QuotationModel)
// GET /api/quotations/:branch - Get all quotations by branch
.get(
"/:branch",
({ params, query }) => {
const { branch } = params;
const { status } = query as { status?: string };
const quotations = service.getAllQuotations(
branch,
status as
| "draft"
| "sent"
| "accepted"
| "rejected"
| "expired"
| undefined,
);
return {
success: true,
data: quotations,
count: quotations.length,
message: `Found ${quotations.length} quotation(s) for branch: ${branch}`,
};
},
{
params: t.Object({
branch: t.String(),
}),
query: t.Optional(
t.Object({
status: t.Optional(
t.Union([
t.Literal("draft"),
t.Literal("sent"),
t.Literal("accepted"),
t.Literal("rejected"),
t.Literal("expired"),
]),
),
}),
),
response: QuotationModel.QuotationList,
detail: {
description: "Get all quotations for a specific branch",
parameters: [
{
name: "branch",
in: "path",
required: true,
schema: { type: "string" },
description:
"Branch identifier (e.g., branch-01, branch-02, head-office)",
},
{
name: "status",
in: "query",
required: false,
schema: {
type: "string",
enum: ["draft", "sent", "accepted", "rejected", "expired"],
},
description: "Filter quotations by status",
},
],
},
},
)
// GET /api/quotations/:branch/:id - Get single quotation by ID
.get(
"/:branch/:id",
({ params }) => {
const { branch, id } = params;
const quotation = service.getQuotationByIdAndBranch(branch, id);
if (!quotation) {
return {
success: false,
error: "Quotation not found",
};
}
return {
success: true,
data: quotation,
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: QuotationModel.Quotation,
}),
t.Object({
success: t.Literal(false),
error: t.String(),
}),
]),
detail: {
description: "Get a single quotation by ID and branch",
},
},
)
// POST /api/quotations - Create new quotation
.post(
"/",
({ body }) => {
const quotation = service.createQuotation(body);
return {
success: true,
data: quotation,
message: "Quotation created successfully",
};
},
{
body: QuotationModel.CreateQuotation,
response: t.Object({
success: t.Boolean(),
data: QuotationModel.Quotation,
message: t.String(),
}),
detail: {
description: "Create a new quotation",
},
},
)
// PUT /api/quotations/:branch/:id - Update quotation
.put(
"/:branch/:id",
({ params, body }) => {
const { branch, id } = params;
const quotation = service.updateQuotation(branch, id, body);
if (!quotation) {
return {
success: false,
error: "Quotation not found",
};
}
return {
success: true,
data: quotation,
message: "Quotation updated successfully",
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
}),
body: QuotationModel.UpdateQuotation,
response: t.Union([
t.Object({
success: t.Literal(true),
data: QuotationModel.Quotation,
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
}),
]),
detail: {
description: "Update an existing quotation",
},
},
)
// DELETE /api/quotations/:branch/:id - Delete quotation
.delete(
"/:branch/:id",
({ params }) => {
const { branch, id } = params;
const quotation = service.deleteQuotation(branch, id);
if (!quotation) {
return {
success: false,
error: "Quotation not found",
};
}
return {
success: true,
data: quotation,
message: "Quotation deleted successfully",
};
},
{
params: t.Object({
branch: t.String(),
id: t.String(),
}),
response: t.Union([
t.Object({
success: t.Literal(true),
data: QuotationModel.Quotation,
message: t.String(),
}),
t.Object({
success: t.Literal(false),
error: t.String(),
}),
]),
detail: {
description: "Delete a quotation",
},
},
);

View File

@@ -0,0 +1,104 @@
import { t } from "elysia";
// Schemas for validation
export const QuotationModel = {
Quotation: t.Object({
id: t.String(),
quotationNumber: t.String(),
branch: t.String(),
customerId: t.String(),
customerName: t.String(),
date: t.String({ format: "date-time" }),
validUntil: t.String({ format: "date-time" }),
subtotal: t.Number(),
taxRate: t.Number(),
taxAmount: t.Number(),
totalAmount: t.Number(),
status: t.Union([
t.Literal("draft"),
t.Literal("sent"),
t.Literal("accepted"),
t.Literal("rejected"),
t.Literal("expired"),
]),
notes: t.Optional(t.String()),
createdAt: t.String({ format: "date-time" }),
updatedAt: t.String({ format: "date-time" }),
}),
CreateQuotation: t.Object({
branch: t.String(),
customerId: t.String(),
customerName: t.String(),
date: t.String({ format: "date-time" }),
validUntil: t.String({ format: "date-time" }),
subtotal: t.Number(),
taxRate: t.Number(),
notes: t.Optional(t.String()),
status: t.Optional(
t.Union([
t.Literal("draft"),
t.Literal("sent"),
t.Literal("accepted"),
t.Literal("rejected"),
t.Literal("expired"),
]),
),
}),
UpdateQuotation: t.Object({
customerId: t.Optional(t.String()),
customerName: t.Optional(t.String()),
date: t.Optional(t.String({ format: "date-time" })),
validUntil: t.Optional(t.String({ format: "date-time" })),
subtotal: t.Optional(t.Number()),
taxRate: t.Optional(t.Number()),
notes: t.Optional(t.String()),
status: t.Optional(
t.Union([
t.Literal("draft"),
t.Literal("sent"),
t.Literal("accepted"),
t.Literal("rejected"),
t.Literal("expired"),
]),
),
}),
QuotationList: t.Object({
success: t.Boolean(),
data: t.Array(
t.Object({
id: t.String(),
quotationNumber: t.String(),
branch: t.String(),
customerId: t.String(),
customerName: t.String(),
date: t.String(),
validUntil: t.String(),
subtotal: t.Number(),
taxRate: t.Number(),
taxAmount: t.Number(),
totalAmount: t.Number(),
status: t.Union([
t.Literal("draft"),
t.Literal("sent"),
t.Literal("accepted"),
t.Literal("rejected"),
t.Literal("expired"),
]),
notes: t.Optional(t.String()),
createdAt: t.String(),
updatedAt: t.String(),
}),
),
count: t.Number(),
message: t.Optional(t.String()),
}),
};
// Export types from schemas
export type Quotation = typeof QuotationModel.Quotation.static;
export type CreateQuotation = typeof QuotationModel.CreateQuotation.static;
export type UpdateQuotation = typeof QuotationModel.UpdateQuotation.static;
export type QuotationList = typeof QuotationModel.QuotationList.static;

View File

@@ -0,0 +1,222 @@
import type { Quotation, CreateQuotation, UpdateQuotation } from "./model";
// Mock quotations data
const mockQuotations: Quotation[] = [
{
id: "quot-001",
quotationNumber: "QT-2024-001",
branch: "branch-01",
customerId: "cust-001",
customerName: "สมชาย ใจดี",
date: "2024-01-20T00:00:00Z",
validUntil: "2024-02-20T00:00:00Z",
subtotal: 50000,
taxRate: 0.07,
taxAmount: 3500,
totalAmount: 53500,
status: "sent",
notes: "Quotation for office supplies",
createdAt: "2024-01-20T09:00:00Z",
updatedAt: "2024-01-20T09:00:00Z",
},
{
id: "quot-002",
quotationNumber: "QT-2024-002",
branch: "branch-01",
customerId: "cust-002",
customerName: "วิภา สุขสันต์",
date: "2024-02-25T00:00:00Z",
validUntil: "2024-03-25T00:00:00Z",
subtotal: 120000,
taxRate: 0.07,
taxAmount: 8400,
totalAmount: 128400,
status: "accepted",
notes: "Quotation for computer equipment",
createdAt: "2024-02-25T10:30:00Z",
updatedAt: "2024-02-28T14:20:00Z",
},
{
id: "quot-003",
quotationNumber: "QT-2024-003",
branch: "branch-02",
customerId: "cust-004",
customerName: "มานี มีสุข",
date: "2024-03-10T00:00:00Z",
validUntil: "2024-04-10T00:00:00Z",
subtotal: 75000,
taxRate: 0.07,
taxAmount: 5250,
totalAmount: 80250,
status: "draft",
notes: null,
createdAt: "2024-03-10T11:00:00Z",
updatedAt: "2024-03-10T11:00:00Z",
},
{
id: "quot-004",
quotationNumber: "QT-2024-004",
branch: "head-office",
customerId: "cust-007",
customerName: "ภูมิ รักษ์โลก",
date: "2024-04-01T00:00:00Z",
validUntil: "2024-05-01T00:00:00Z",
subtotal: 200000,
taxRate: 0.07,
taxAmount: 14000,
totalAmount: 214000,
status: "sent",
notes: "Quotation for laboratory equipment",
createdAt: "2024-04-01T09:30:00Z",
updatedAt: "2024-04-01T09:30:00Z",
},
];
/**
* Get all quotations for a specific branch
* @param branch - Branch identifier
* @param status - Optional status filter
* @returns Array of quotations
*/
export function getAllQuotations(
branch: string,
status?: "draft" | "sent" | "accepted" | "rejected" | "expired",
): Quotation[] {
let quotations = mockQuotations.filter((q) => q.branch === branch);
if (status) {
quotations = quotations.filter((q) => q.status === status);
}
return quotations;
}
/**
* Get a single quotation by ID and branch
* @param branch - Branch identifier
* @param id - Quotation ID
* @returns Quotation or undefined if not found
*/
export function getQuotationByIdAndBranch(
branch: string,
id: string,
): Quotation | undefined {
const quotation = mockQuotations.find((q) => q.id === id);
// Only return if quotation belongs to the specified branch
if (quotation && quotation.branch === branch) {
return quotation;
}
return undefined;
}
/**
* Calculate tax and total amounts
* @param subtotal - Subtotal amount
* @param taxRate - Tax rate (e.g., 0.07 for 7%)
* @returns Object with taxAmount and totalAmount
*/
function calculateTotals(subtotal: number, taxRate: number) {
const taxAmount = subtotal * taxRate;
const totalAmount = subtotal + taxAmount;
return { taxAmount, totalAmount };
}
/**
* Create a new quotation
* @param data - Quotation creation data
* @returns Newly created quotation
*/
export function createQuotation(data: CreateQuotation): Quotation {
const { taxAmount, totalAmount } = calculateTotals(
data.subtotal,
data.taxRate,
);
const newQuotation: Quotation = {
id: `quot-${Date.now()}`,
quotationNumber: `QT-${new Date().getFullYear()}-${String(mockQuotations.length + 1).padStart(3, "0")}`,
...data,
taxAmount,
totalAmount,
status: data.status || "draft",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// In a real app, this would save to database
mockQuotations.push(newQuotation);
return newQuotation;
}
/**
* Update an existing quotation
* @param branch - Branch identifier
* @param id - Quotation ID
* @param data - Quotation update data
* @returns Updated quotation or undefined if not found
*/
export function updateQuotation(
branch: string,
id: string,
data: UpdateQuotation,
): Quotation | undefined {
const quotation = getQuotationByIdAndBranch(branch, id);
if (!quotation) {
return undefined;
}
// Recalculate totals if subtotal or taxRate changed
let { taxAmount, totalAmount } = quotation;
if (data.subtotal !== undefined || data.taxRate !== undefined) {
const newSubtotal = data.subtotal ?? quotation.subtotal;
const newTaxRate = data.taxRate ?? quotation.taxRate;
const calculated = calculateTotals(newSubtotal, newTaxRate);
taxAmount = calculated.taxAmount;
totalAmount = calculated.totalAmount;
}
// Merge update data
const updatedQuotation: Quotation = {
...quotation,
...data,
taxAmount,
totalAmount,
updatedAt: new Date().toISOString(),
};
// In a real app, this would update database
const index = mockQuotations.findIndex((q) => q.id === id);
if (index !== -1) {
mockQuotations[index] = updatedQuotation;
}
return updatedQuotation;
}
/**
* Delete a quotation
* @param branch - Branch identifier
* @param id - Quotation ID
* @returns Deleted quotation or undefined if not found
*/
export function deleteQuotation(
branch: string,
id: string,
): Quotation | undefined {
const quotation = getQuotationByIdAndBranch(branch, id);
if (!quotation) {
return undefined;
}
// In a real app, this would delete from database
const index = mockQuotations.findIndex((q) => q.id === id);
if (index !== -1) {
mockQuotations.splice(index, 1);
}
return quotation;
}

View File

@@ -0,0 +1,86 @@
"use client";
import {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import {
initKeycloak,
logout as keycloakLogout,
getUserInfo,
isAuthenticated as isKeycloakAuthenticated,
} from "@/lib/keycloak-client";
interface AuthContextType {
isAuthenticated: boolean;
isLoading: boolean;
userInfo: any;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [userInfo, setUserInfo] = useState<any>(null);
useEffect(() => {
let mounted = true;
async function initAuth() {
try {
const authenticated = await initKeycloak();
if (mounted) {
setIsAuthenticated(authenticated);
setIsLoading(false);
if (authenticated) {
setUserInfo(getUserInfo());
}
}
} catch (error) {
console.error("Auth initialization failed:", error);
if (mounted) {
setIsLoading(false);
}
}
}
initAuth();
return () => {
mounted = false;
};
}, []);
const handleLogout = async () => {
await keycloakLogout();
setIsAuthenticated(false);
setUserInfo(null);
};
return (
<AuthContext.Provider
value={{
isAuthenticated,
isLoading,
userInfo,
logout: handleLogout,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

31
src/types/customer.ts Normal file
View File

@@ -0,0 +1,31 @@
export interface Customer {
id: string;
branch: string;
name: string;
email: string;
phone: string;
company: string;
address: string;
status: "active" | "inactive" | "pending";
createdAt: string;
updatedAt: string;
}
export interface CreateCustomerInput {
branch: string;
name: string;
email: string;
phone: string;
company: string;
address: string;
status?: "active" | "inactive" | "pending";
}
export interface UpdateCustomerInput {
name?: string;
email?: string;
phone?: string;
company?: string;
address?: string;
status?: "active" | "inactive" | "pending";
}