Docker Commit
This commit is contained in:
parent
541f2ac0c0
commit
76e0eb782d
7 changed files with 178 additions and 70 deletions
12
.dockerignore
Normal file
12
.dockerignore
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
portal-data.json
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
20
.env.example
20
.env.example
|
|
@ -1,9 +1,13 @@
|
||||||
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
# Server Configuration
|
||||||
# AI Studio automatically injects this at runtime from user secrets.
|
PORT=3000
|
||||||
# Users configure this via the Secrets panel in the AI Studio UI.
|
NODE_ENV=production
|
||||||
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
|
||||||
|
|
||||||
# APP_URL: The URL where this applet is hosted.
|
# Database & Data Persistence Path
|
||||||
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
DATA_PATH=/app/data/portal-data.json
|
||||||
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
|
||||||
APP_URL="MY_APP_URL"
|
# Optional SMTP Email Configuration (If using real SMTP server)
|
||||||
|
# SMTP_HOST=smtp.gmail.com
|
||||||
|
# SMTP_PORT=587
|
||||||
|
# SMTP_USER=your-email@domain.com
|
||||||
|
# SMTP_PASS=your-app-password
|
||||||
|
# SMTP_FROM="Balance Sheet Portal <noreply@domain.com>"
|
||||||
|
|
|
||||||
69
COOLIFY.md
Normal file
69
COOLIFY.md
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# Deployment Guide for Coolify
|
||||||
|
|
||||||
|
This repository is optimized for deployment on **Coolify** using Docker or Docker Compose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Deployment Options in Coolify
|
||||||
|
|
||||||
|
### Option A: Deployment via Git Repository (Recommended)
|
||||||
|
|
||||||
|
1. **Push Code to Git**: Push this repository to GitHub, GitLab, or your self-hosted Git service.
|
||||||
|
2. **Add New Resource in Coolify**:
|
||||||
|
- Go to your Coolify dashboard.
|
||||||
|
- Click **+ Add Resource** -> **Public Repository** or **Private Repository**.
|
||||||
|
- Paste your repository URL and select the `main` branch.
|
||||||
|
3. **Select Build Pack**:
|
||||||
|
- Select **Dockerfile**. Coolify will automatically detect the `Dockerfile` in the root directory.
|
||||||
|
4. **Configure Port & Network**:
|
||||||
|
- **Port**: Set `3000`.
|
||||||
|
5. **Configure Persistent Volume**:
|
||||||
|
- Under **Storage / Volumes**, add a persistent volume mapping to prevent data loss on container redeployments:
|
||||||
|
- **Destination Path**: `/app/data`
|
||||||
|
6. **Deploy**:
|
||||||
|
- Click **Deploy**. Coolify will build the multi-stage Docker image and start your application.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option B: Deployment via Docker Compose
|
||||||
|
|
||||||
|
1. **Add New Resource in Coolify**:
|
||||||
|
- Click **+ Add Resource** -> **Docker Compose**.
|
||||||
|
2. **Source Code**:
|
||||||
|
- Select your Git Repository or paste the contents of `docker-compose.yml`.
|
||||||
|
3. **Environment Variables**:
|
||||||
|
- Add the following environment variables in Coolify UI:
|
||||||
|
```env
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
DATA_PATH=/app/data/portal-data.json
|
||||||
|
```
|
||||||
|
4. **Deploy**:
|
||||||
|
- Click **Deploy**. Coolify will spin up the container and attach the `portal_data` volume automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local Testing with Docker
|
||||||
|
|
||||||
|
To build and test locally before deploying to Coolify:
|
||||||
|
|
||||||
|
### Using Docker Compose:
|
||||||
|
```bash
|
||||||
|
docker-compose up --build -d
|
||||||
|
```
|
||||||
|
Access the app at: `http://localhost:3000`
|
||||||
|
|
||||||
|
### Using Docker directly:
|
||||||
|
```bash
|
||||||
|
docker build -t balance-sheet-portal .
|
||||||
|
docker run -p 3000:3000 -v portal_data:/app/data balance-sheet-portal
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Health Check Endpoint
|
||||||
|
|
||||||
|
The container includes a built-in health check targeting:
|
||||||
|
`GET /api/health`
|
||||||
|
|
||||||
|
It returns `{"status": "ok"}` when the Express server and Vite static engine are healthy.
|
||||||
29
docker-compose.yml
Normal file
29
docker-compose.yml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
balance-sheet-portal:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: balance-sheet-portal:latest
|
||||||
|
container_name: balance-sheet-portal
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${PORT:-3000}:3000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=3000
|
||||||
|
- DATA_PATH=/app/data/portal-data.json
|
||||||
|
volumes:
|
||||||
|
- portal_data:/app/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
portal_data:
|
||||||
|
name: balance_sheet_portal_data
|
||||||
|
driver: local
|
||||||
|
|
@ -40,7 +40,7 @@ import {
|
||||||
import { AccountingEngine, DEFAULT_FX_RATES } from './src/services/accountingEngine.js';
|
import { AccountingEngine, DEFAULT_FX_RATES } from './src/services/accountingEngine.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = 3000;
|
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
|
@ -62,7 +62,8 @@ interface DB {
|
||||||
pkrRates?: Record<string, number>;
|
pkrRates?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbPath = path.join(process.cwd(), 'portal-data.json');
|
const dataDir = process.env.DATA_DIR || process.cwd();
|
||||||
|
const dbPath = process.env.DATA_PATH || path.join(dataDir, 'portal-data.json');
|
||||||
|
|
||||||
const DEFAULT_PKR_RATES: Record<string, number> = {
|
const DEFAULT_PKR_RATES: Record<string, number> = {
|
||||||
USDPKR: 278.16,
|
USDPKR: 278.16,
|
||||||
|
|
@ -177,6 +178,10 @@ function loadDB(): DB {
|
||||||
|
|
||||||
function saveDB(data: DB) {
|
function saveDB(data: DB) {
|
||||||
try {
|
try {
|
||||||
|
const parentDir = path.dirname(dbPath);
|
||||||
|
if (!fs.existsSync(parentDir)) {
|
||||||
|
fs.mkdirSync(parentDir, { recursive: true });
|
||||||
|
}
|
||||||
fs.writeFileSync(dbPath, JSON.stringify(data, null, 2));
|
fs.writeFileSync(dbPath, JSON.stringify(data, null, 2));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error saving portal-data.json:', err);
|
console.error('Error saving portal-data.json:', err);
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,11 @@ export default function App() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle user logout
|
||||||
|
const handleLogout = () => {
|
||||||
|
setIsAuthModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-screen w-screen bg-slate-950 text-slate-100 flex flex-col md:flex-row overflow-hidden font-sans antialiased selection:bg-cyan-500 selection:text-black">
|
<div className="relative h-screen w-screen bg-slate-950 text-slate-100 flex flex-col md:flex-row overflow-hidden font-sans antialiased selection:bg-cyan-500 selection:text-black">
|
||||||
{/* 3D WebGL Holographic Particle Canvas Background */}
|
{/* 3D WebGL Holographic Particle Canvas Background */}
|
||||||
|
|
@ -90,6 +95,7 @@ export default function App() {
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
setActiveTab={setActiveTab}
|
setActiveTab={setActiveTab}
|
||||||
allUsers={allUsers}
|
allUsers={allUsers}
|
||||||
|
onLogout={handleLogout}
|
||||||
onOpenAuthModal={() => setIsAuthModalOpen(true)}
|
onOpenAuthModal={() => setIsAuthModalOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -105,7 +111,7 @@ export default function App() {
|
||||||
setActivePeriod={setActivePeriod}
|
setActivePeriod={setActivePeriod}
|
||||||
periodsList={periodsList}
|
periodsList={periodsList}
|
||||||
allUsers={allUsers}
|
allUsers={allUsers}
|
||||||
onResetSeed={handleResetSeed}
|
onLogout={handleLogout}
|
||||||
onOpenAuthModal={() => setIsAuthModalOpen(true)}
|
onOpenAuthModal={() => setIsAuthModalOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@ import {
|
||||||
PanelLeftOpen,
|
PanelLeftOpen,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Key
|
Key,
|
||||||
|
LogOut
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { User, BranchId, BRANCHES_LIST } from '../types';
|
import { User, BranchId, BRANCHES_LIST } from '../types';
|
||||||
|
|
||||||
|
|
@ -38,7 +39,8 @@ interface NavigationProps {
|
||||||
setActivePeriod: (period: string) => void;
|
setActivePeriod: (period: string) => void;
|
||||||
periodsList: string[];
|
periodsList: string[];
|
||||||
allUsers: User[];
|
allUsers: User[];
|
||||||
onResetSeed: () => void;
|
onLogout?: () => void;
|
||||||
|
onResetSeed?: () => void;
|
||||||
onOpenAuthModal?: () => void;
|
onOpenAuthModal?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,6 +51,7 @@ export const Sidebar: React.FC<Omit<NavigationProps, 'activePeriod' | 'setActive
|
||||||
activeTab,
|
activeTab,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
allUsers,
|
allUsers,
|
||||||
|
onLogout,
|
||||||
onOpenAuthModal
|
onOpenAuthModal
|
||||||
}) => {
|
}) => {
|
||||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||||
|
|
@ -315,16 +318,11 @@ export const Sidebar: React.FC<Omit<NavigationProps, 'activePeriod' | 'setActive
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Sidebar Footer User Profile */}
|
{/* Sidebar Footer User Profile */}
|
||||||
<div className="p-2 border-t border-cyan-500/20 bg-slate-950/90 relative shrink-0">
|
<div className="p-2 border-t border-cyan-500/20 bg-slate-950/90 relative shrink-0 space-y-1.5">
|
||||||
<button
|
<div
|
||||||
onClick={() => {
|
|
||||||
setShowUserMenu(false);
|
|
||||||
onOpenAuthModal?.();
|
|
||||||
}}
|
|
||||||
className={`w-full flex items-center ${
|
className={`w-full flex items-center ${
|
||||||
isCollapsed ? 'justify-center p-2' : 'justify-between p-2'
|
isCollapsed ? 'justify-center p-2' : 'justify-between p-2'
|
||||||
} rounded-xl bg-slate-900/60 border border-cyan-500/30 hover:border-cyan-400/60 transition-all text-left cursor-pointer shadow-sm`}
|
} rounded-xl bg-slate-900/60 border border-cyan-500/30 text-left`}
|
||||||
title={`Active Session: ${currentUser.name} - Click to Authenticate Credentials`}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5 overflow-hidden">
|
<div className="flex items-center gap-2.5 overflow-hidden">
|
||||||
<div className="w-8 h-8 rounded-full bg-cyan-500/20 border border-cyan-400/60 flex items-center justify-center text-cyan-300 font-bold text-xs shrink-0 shadow-[0_0_10px_rgba(6,182,212,0.4)]">
|
<div className="w-8 h-8 rounded-full bg-cyan-500/20 border border-cyan-400/60 flex items-center justify-center text-cyan-300 font-bold text-xs shrink-0 shadow-[0_0_10px_rgba(6,182,212,0.4)]">
|
||||||
|
|
@ -336,12 +334,24 @@ export const Sidebar: React.FC<Omit<NavigationProps, 'activePeriod' | 'setActive
|
||||||
{currentUser.name}
|
{currentUser.name}
|
||||||
{isAdmin && <ShieldCheck className="w-3 h-3 text-cyan-400 shrink-0" />}
|
{isAdmin && <ShieldCheck className="w-3 h-3 text-cyan-400 shrink-0" />}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] text-cyan-400 font-mono truncate font-semibold">Authenticate / Switch</p>
|
<p className="text-[10px] text-cyan-400 font-mono truncate font-semibold">
|
||||||
|
{isAdmin ? 'Head Office Admin' : currentBranch?.name || 'Branch User'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!isCollapsed && <Key className="w-3.5 h-3.5 text-amber-400 shrink-0" />}
|
</div>
|
||||||
</button>
|
|
||||||
|
{!isCollapsed && onLogout && (
|
||||||
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
className="w-full py-2 px-3 rounded-xl bg-slate-900 hover:bg-rose-950/50 border border-rose-500/40 hover:border-rose-500/70 text-rose-300 hover:text-rose-100 text-xs font-bold flex items-center justify-center gap-2 transition-all cursor-pointer shadow-sm shadow-rose-950/20"
|
||||||
|
title="Sign out of current banking session"
|
||||||
|
>
|
||||||
|
<LogOut className="w-3.5 h-3.5 text-rose-400" />
|
||||||
|
<span>Sign Out</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|
@ -357,6 +367,7 @@ export const HeaderBar: React.FC<NavigationProps> = ({
|
||||||
setActivePeriod,
|
setActivePeriod,
|
||||||
periodsList,
|
periodsList,
|
||||||
allUsers,
|
allUsers,
|
||||||
|
onLogout,
|
||||||
onResetSeed,
|
onResetSeed,
|
||||||
onOpenAuthModal
|
onOpenAuthModal
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -439,25 +450,16 @@ export const HeaderBar: React.FC<NavigationProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{onOpenAuthModal && (
|
{onLogout && (
|
||||||
<button
|
<button
|
||||||
onClick={onOpenAuthModal}
|
onClick={onLogout}
|
||||||
className="bg-gradient-to-r from-cyan-500/20 via-blue-500/20 to-indigo-500/20 hover:from-cyan-500/30 hover:to-indigo-500/30 text-cyan-300 text-xs px-2.5 py-1.5 rounded-lg font-bold border border-cyan-400/50 transition-all flex items-center gap-1.5 cursor-pointer shadow-[0_0_10px_rgba(6,182,212,0.2)]"
|
className="bg-slate-900 hover:bg-rose-950/50 text-rose-300 hover:text-rose-200 text-xs px-3 py-1.5 rounded-lg font-bold border border-rose-500/40 hover:border-rose-500/70 transition-all flex items-center gap-1.5 cursor-pointer shadow-sm shadow-rose-950/20"
|
||||||
title="Authenticate Branch Credentials or Switch User Persona"
|
title="Sign out of current active session"
|
||||||
>
|
>
|
||||||
<ShieldCheck className="w-3.5 h-3.5 text-cyan-400" />
|
<LogOut className="w-3.5 h-3.5 text-rose-400" />
|
||||||
<span className="hidden sm:inline">Auth Matrix</span>
|
<span className="hidden sm:inline">Sign Out</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onResetSeed}
|
|
||||||
className="bg-slate-900 hover:bg-slate-800 text-cyan-300 hover:text-white text-xs px-2.5 py-1.5 rounded-lg font-semibold border border-cyan-500/30 transition-all flex items-center gap-1.5 cursor-pointer shadow-sm hover:shadow-[0_0_15px_rgba(6,182,212,0.3)]"
|
|
||||||
title="Reset sample data back to baseline"
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-3.5 h-3.5 text-cyan-400" />
|
|
||||||
<span className="hidden sm:inline">Reset Demo</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -582,38 +584,19 @@ export const HeaderBar: React.FC<NavigationProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick User Identity Switcher for Mobile */}
|
{/* Sign Out Button for Mobile */}
|
||||||
<div className="pt-2 border-t border-slate-800">
|
<div className="pt-2 border-t border-slate-800">
|
||||||
<button
|
{onLogout && (
|
||||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
<button
|
||||||
className="w-full flex items-center justify-between p-2 bg-slate-900 rounded-lg border border-cyan-500/30 text-xs text-cyan-300"
|
onClick={() => {
|
||||||
>
|
setMobileMenuOpen(false);
|
||||||
<span>Simulate User: {currentUser.name}</span>
|
onLogout();
|
||||||
<ChevronDown className="w-4 h-4 text-cyan-400" />
|
}}
|
||||||
</button>
|
className="w-full flex items-center justify-center gap-2 p-2.5 bg-slate-900 hover:bg-rose-950/60 border border-rose-500/40 text-rose-300 text-xs font-bold rounded-xl transition-all cursor-pointer"
|
||||||
|
>
|
||||||
{showUserMenu && (
|
<LogOut className="w-4 h-4 text-rose-400" />
|
||||||
<div className="mt-2 p-2 bg-slate-900 rounded-lg border border-cyan-500/40 space-y-1 max-h-48 overflow-y-auto">
|
<span>Sign Out / Logout</span>
|
||||||
{allUsers.filter((u) => u.approved).map((user) => (
|
</button>
|
||||||
<button
|
|
||||||
key={user.id}
|
|
||||||
onClick={() => {
|
|
||||||
setCurrentUser(user);
|
|
||||||
setShowUserMenu(false);
|
|
||||||
setMobileMenuOpen(false);
|
|
||||||
if (user.role === 'branch_user') {
|
|
||||||
setActiveTab('branch_input');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`w-full text-left p-1.5 rounded text-xs flex justify-between items-center ${
|
|
||||||
user.id === currentUser.id ? 'bg-cyan-500/30 text-white font-bold' : 'text-slate-300 hover:bg-slate-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span>{user.name}</span>
|
|
||||||
<span className="text-[9px] font-mono text-cyan-400">{user.role}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue