Initial commit: ArchiTools modular dashboard platform

Complete Next.js 16 application with 13 fully implemented modules:
Email Signature, Word XML Generator, Registratura, Dashboard,
Tag Manager, IT Inventory, Address Book, Password Vault,
Mini Utilities, Prompt Generator, Digital Signatures,
Word Templates, and AI Chat.

Includes core platform systems (module registry, feature flags,
storage abstraction, i18n, theming, auth stub, tagging),
16 technical documentation files, Docker deployment config,
and legacy HTML tool reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Marius Tarau
2026-02-17 12:50:25 +02:00
commit 4c46e8bcdd
189 changed files with 33780 additions and 0 deletions

View File

@@ -0,0 +1,684 @@
# Coding Standards
Conventions and rules for all code in the ArchiTools repository.
---
## Language
TypeScript in strict mode. The `tsconfig.json` must include:
```json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"forceConsistentCasingInFileNames": true
}
}
```
**No `any`** unless there is a documented, unavoidable reason (e.g., third-party library with missing types). When `any` is truly necessary, use `// eslint-disable-next-line @typescript-eslint/no-explicit-any` with a comment explaining why.
Prefer `unknown` over `any` when the type is genuinely unknown. Narrow with type guards.
---
## File Naming
| Entity | Convention | Example |
|---|---|---|
| Files (components) | kebab-case | `signature-preview.tsx` |
| Files (hooks) | kebab-case with `use-` prefix | `use-signature-builder.ts` |
| Files (utils/services) | kebab-case | `sanitize-xml-name.ts` |
| Files (types) | `types.ts` within module | `types.ts` |
| Files (tests) | `*.test.ts` / `*.test.tsx` | `sanitize-xml-name.test.ts` |
| Component names | PascalCase | `SignaturePreview` |
| Hook names | camelCase with `use` prefix | `useSignatureBuilder` |
| Utility functions | camelCase | `sanitizeXmlName` |
| Constants | UPPER_SNAKE_CASE | `DEFAULT_NAMESPACE` |
| Type/Interface names | PascalCase | `SignatureConfig` |
| Enum values | PascalCase | `FieldVariant.Upper` |
---
## Component Patterns
### Functional Components Only
No class components. All components are functions.
```tsx
// Correct
export function SignaturePreview({ config }: SignaturePreviewProps) {
return <div>...</div>;
}
// Wrong: arrow function export (less debuggable in stack traces)
export const SignaturePreview = ({ config }: SignaturePreviewProps) => { ... };
// Wrong: default export
export default function SignaturePreview() { ... }
```
Exception: arrow functions are acceptable for small inline components passed as props or used in `.map()`.
### Props Interface
Define the props interface directly above the component, in the same file. Suffix with `Props`.
```tsx
interface SignaturePreviewProps {
config: SignatureConfig;
className?: string;
onExport?: () => void;
}
export function SignaturePreview({ config, className, onExport }: SignaturePreviewProps) {
// ...
}
```
If the props interface is reused across multiple files, define it in the module's `types.ts` and import it.
### Named Exports
All exports are named. No default exports anywhere in the codebase.
Rationale: named exports enforce consistent import names, improve refactoring, and work better with tree-shaking.
```tsx
// Correct
export function SignaturePreview() { ... }
export function useSignatureBuilder() { ... }
export interface SignatureConfig { ... }
// Wrong
export default function SignaturePreview() { ... }
```
### Co-located Styles
All styling is done with Tailwind classes in JSX. No CSS modules, no styled-components, no separate `.css` files per component.
```tsx
// Correct
<div className="rounded-xl border bg-card p-6 shadow-sm">
// Wrong: external CSS file
import styles from './signature-preview.module.css';
<div className={styles.card}>
```
Global styles live only in `src/app/globals.css` (theme tokens, font imports, base resets).
### Conditional Classes
Use the `cn()` utility (from `src/shared/utils/cn.ts`, wrapping `clsx` + `tailwind-merge`):
```tsx
import { cn } from '@/shared/utils/cn';
<div className={cn(
'rounded-xl border p-6',
isActive && 'border-primary bg-primary/5',
className
)} />
```
---
## Hook Patterns
### Naming
All hooks start with `use`. File names match: `use-signature-builder.ts` exports `useSignatureBuilder`.
### Return Type
Return a typed object, not a tuple/array.
```tsx
// Correct
export function useSignatureBuilder(config: SignatureConfig): SignatureBuilderState {
// ...
return { html, previewData, isGenerating };
}
// Wrong: tuple return (positional args are fragile)
export function useSignatureBuilder(config: SignatureConfig) {
return [html, previewData, isGenerating];
}
```
Exception: simple two-value hooks that mirror `useState` semantics may return a tuple if the pattern is unambiguous: `const [value, setValue] = useSomeState()`.
### Single Responsibility
Each hook does one thing. If a hook grows beyond ~80 lines, consider splitting.
```
useSignatureConfig -- manages form state
useSignatureBuilder -- generates HTML from config
useSignatureExport -- handles file download
```
Not:
```
useSignature -- does everything
```
### Dependencies
Hooks that depend on external services (storage, API) receive them as parameters:
```tsx
export function useCategoryManager(storage: StorageAdapter): CategoryManagerState {
// ...
}
```
This enables testing with mock storage.
---
## Service Patterns
Services are modules that encapsulate business logic outside of React's component lifecycle.
### Pure Functions Where Possible
```tsx
// Correct: pure function, easy to test
export function sanitizeXmlName(name: string): string | null {
// ...
}
// Correct: pure function with explicit dependencies
export function generateXml(input: XmlGeneratorInput): XmlGeneratorResult {
// ...
}
```
### Accept Dependencies via Parameters
```tsx
// Correct: zip library passed in (or imported at module level, testable via jest.mock)
export async function createZipArchive(
files: Record<string, string>,
zipFactory: () => JSZip = () => new JSZip()
): Promise<Blob> {
const zip = zipFactory();
// ...
}
```
### No Direct DOM Access
Services must never call `document.*`, `window.localStorage`, or any browser API directly. All browser interactions are mediated through hooks or adapters that are injected.
```tsx
// Wrong: service directly accessing DOM
export function downloadFile(content: string) {
const a = document.createElement('a'); // NO
// ...
}
// Correct: service returns data, hook handles DOM interaction
export function prepareDownload(content: string, filename: string): DownloadPayload {
return { blob: new Blob([content]), filename };
}
```
---
## Import Ordering
Imports are grouped in this order, separated by blank lines:
```tsx
// 1. React
import { useState, useCallback } from 'react';
// 2. Next.js
import { useRouter } from 'next/navigation';
// 3. Third-party libraries
import JSZip from 'jszip';
import { z } from 'zod';
// 4. Core (@/core)
import { labels } from '@/core/i18n/labels';
import { useStorage } from '@/core/storage/use-storage';
// 5. Shared (@/shared)
import { Button } from '@/shared/components/ui/button';
import { cn } from '@/shared/utils/cn';
// 6. Module-level (@/modules)
import { useXmlGenerator } from '@/modules/xml-generator/hooks/use-xml-generator';
// 7. Relative imports (same module)
import { SignaturePreview } from './signature-preview';
import type { SignatureConfig } from '../types';
```
Enforce with ESLint `import/order` rule.
---
## Path Aliases
Configured in `tsconfig.json`:
```json
{
"compilerOptions": {
"paths": {
"@/core/*": ["./src/core/*"],
"@/shared/*": ["./src/shared/*"],
"@/modules/*": ["./src/modules/*"],
"@/config/*": ["./src/config/*"]
}
}
}
```
**Rules:**
- Always use path aliases for cross-boundary imports.
- Use relative imports only within the same module directory.
- Never use `../../../` chains that cross module boundaries.
```tsx
// Correct: cross-boundary via alias
import { labels } from '@/core/i18n/labels';
// Correct: within same module via relative
import { SignaturePreview } from './signature-preview';
// Wrong: relative path crossing module boundary
import { labels } from '../../../core/i18n/labels';
```
---
## No Barrel Re-exports from Module Boundaries
Do not create `index.ts` files that re-export everything from a module.
```
// WRONG: src/modules/xml-generator/index.ts
export * from './hooks/use-xml-generator';
export * from './hooks/use-category-manager';
export * from './utils/sanitize-xml-name';
export * from './types';
```
Rationale: barrel files break tree-shaking in Next.js, cause circular dependency issues, and make import costs invisible. Import directly from the specific file.
```tsx
// Correct: direct import
import { useXmlGenerator } from '@/modules/xml-generator/hooks/use-xml-generator';
// Wrong: barrel import
import { useXmlGenerator } from '@/modules/xml-generator';
```
Exception: `src/shared/components/ui/` may use barrel re-exports since shadcn/ui generates them and they are leaf components with no circular dependency risk.
---
## Type Conventions
### `interface` vs `type`
- Use `interface` for object shapes (props, configs, data models).
- Use `type` for unions, intersections, mapped types, and utility types.
```tsx
// Object shape: interface
interface SignatureConfig {
prefix: string;
name: string;
colors: ColorMap;
}
// Union: type
type FieldVariant = 'base' | 'short' | 'upper' | 'lower' | 'initials' | 'first';
// Intersection: type
type SignatureWithMeta = SignatureConfig & { id: string; createdAt: string };
// Mapped type: type
type PartialSignature = Partial<SignatureConfig>;
```
### Naming Suffixes
| Suffix | Usage | Example |
|---|---|---|
| `Props` | Component props | `SignaturePreviewProps` |
| `State` | Hook return types | `SignatureBuilderState` |
| `Result` | Service return types | `XmlGeneratorResult` |
| `Config` | Configuration objects | `SignatureConfig` |
| `Input` | Service/function input parameters | `XmlGeneratorInput` |
### Enums
Prefer union types over TypeScript enums for simple value sets:
```tsx
// Preferred
type GeneratorMode = 'simple' | 'advanced';
// Acceptable for larger sets with associated logic
enum FieldVariant {
Base = 'base',
Short = 'short',
Upper = 'upper',
Lower = 'lower',
Initials = 'initials',
First = 'first',
}
```
---
## Error Handling
### Result Pattern for Services
Service functions that can fail return a discriminated union instead of throwing:
```tsx
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
export function parseXmlConfig(raw: string): Result<XmlConfig> {
try {
const parsed = JSON.parse(raw);
// validate...
return { success: true, data: parsed };
} catch (e) {
return { success: false, error: new Error('Invalid configuration format') };
}
}
```
### try/catch at Boundaries
Use `try/catch` only at the boundary between services and UI (in hooks or event handlers):
```tsx
export function useXmlExport() {
const handleDownload = useCallback(async () => {
try {
const blob = await createZipArchive(files);
triggerDownload(blob, filename);
} catch (error) {
toast.error(labels.common.exportFailed);
}
}, [files, filename]);
return { handleDownload };
}
```
Do not scatter `try/catch` through utility functions. Let errors propagate to the boundary.
### Never Swallow Errors Silently
```tsx
// Wrong: silent failure
try { storage.set('key', data); } catch (e) {}
// Correct: at minimum, log
try {
storage.set('key', data);
} catch (error) {
console.error('Failed to persist data:', error);
}
```
---
## Comments
### When to Comment
- Non-obvious business logic (e.g., "POT = SuprafataConstruitaLaSol / SuprafataTeren per Romanian building code").
- Workarounds for known issues (link to issue/PR).
- Regex patterns (always explain what the regex matches).
- Magic numbers that cannot be replaced with named constants.
### When Not to Comment
- Self-explanatory code. If the function is `sanitizeXmlName`, do not add `/** Sanitizes an XML name */`.
- JSDoc on every function. Add JSDoc only on public API boundaries of shared utilities.
- TODO comments without an owner or issue reference.
### Comment Style
```tsx
// Single-line comments for inline explanations.
/**
* Multi-line JSDoc only for shared utility public API.
* Describe what the function does, not how.
*/
export function formatPhoneNumber(raw: string): PhoneFormatResult {
// Romanian mobile numbers: 07xx xxx xxx -> +40 7xx xxx xxx
// ...
}
```
---
## Romanian Text
All user-facing Romanian text lives exclusively in `src/core/i18n/labels.ts`.
**Components never contain inline Romanian strings:**
```tsx
// Wrong
<Button>Descarca HTML</Button>
// Correct
import { labels } from '@/core/i18n/labels';
<Button>{labels.signature.exportHtml}</Button>
```
**Exceptions:**
- Test files may contain Romanian strings as test fixtures.
- Data files (presets, defaults) may contain Romanian field names that are domain terms, not UI labels.
Romanian diacritics in label values: use them where correct (`Semnatura` vs `Semnatura` -- prefer with diacritics in label constants if the display context supports it). For technical identifiers (XML field names, CSS classes, file paths), never use diacritics.
---
## Git Conventions
### Commit Messages
Follow [Conventional Commits](https://www.conventionalcommits.org/):
```
<type>(<scope>): <description>
[optional body]
[optional footer]
```
**Types:**
| Type | Usage |
|---|---|
| `feat` | New feature |
| `fix` | Bug fix |
| `refactor` | Code restructuring without behavior change |
| `docs` | Documentation only |
| `style` | Formatting, whitespace (not CSS) |
| `test` | Adding or fixing tests |
| `chore` | Build, tooling, dependencies |
| `perf` | Performance improvement |
**Scope:** module name or area (`signature`, `xml-generator`, `core`, `ui`, `config`).
**Examples:**
```
feat(signature): add multi-company support to signature builder
fix(xml-generator): handle duplicate field names in category
refactor(core): extract storage abstraction from localStorage calls
docs(guides): add HTML tool integration plan
chore(deps): upgrade shadcn/ui to 2.1.0
```
### Branching
- `main` -- production-ready code. Protected. No direct pushes.
- `feature/<description>` -- feature branches, branched from `main`.
- `fix/<description>` -- bugfix branches.
- `chore/<description>` -- maintenance branches.
### Pull Requests
- One logical change per PR.
- PR title follows conventional commit format.
- PR description includes: what changed, why, how to test.
- All CI checks must pass before merge.
- Squash merge to `main` (clean linear history).
---
## Anti-Patterns
The following patterns are explicitly prohibited in this codebase. Each entry includes the reason and the correct alternative.
### 1. `any` as escape hatch
```tsx
// Wrong
const data: any = fetchSomething();
// Correct
const data: unknown = fetchSomething();
if (isValidConfig(data)) { /* narrow type */ }
```
### 2. Default exports
```tsx
// Wrong
export default function Page() { ... }
// Correct (Next.js pages are the sole exception -- App Router requires default exports for page.tsx/layout.tsx)
// For page.tsx and layout.tsx ONLY, default export is acceptable because Next.js requires it.
export default function Page() { ... }
// Everything else: named exports
export function SignatureForm() { ... }
```
### 3. Direct DOM manipulation in components
```tsx
// Wrong
useEffect(() => {
document.getElementById('preview')!.innerHTML = html;
}, [html]);
// Correct
return <div dangerouslySetInnerHTML={{ __html: html }} />;
// Or better: render structured JSX from data
```
### 4. Inline Romanian strings
```tsx
// Wrong
<Label>Nume si Prenume</Label>
// Correct
<Label>{labels.signature.fieldName}</Label>
```
### 5. God hooks
```tsx
// Wrong: hook that manages 15 pieces of state and 10 callbacks
export function useEverything() { ... }
// Correct: split by responsibility
export function useSignatureConfig() { ... }
export function useSignatureBuilder() { ... }
export function useSignatureExport() { ... }
```
### 6. Barrel re-exports at module boundary
```tsx
// Wrong: src/modules/xml-generator/index.ts
export * from './hooks/use-xml-generator';
// Correct: import directly from source file
import { useXmlGenerator } from '@/modules/xml-generator/hooks/use-xml-generator';
```
### 7. Business logic in components
```tsx
// Wrong: XML sanitization logic inline in JSX
function XmlForm() {
const sanitize = (name: string) => {
let n = name.replace(/\s+/g, '_');
// 20 more lines of logic...
};
}
// Correct: extract to utility, test separately
import { sanitizeXmlName } from '../utils/sanitize-xml-name';
```
### 8. Untyped hook returns
```tsx
// Wrong: caller has to guess the shape
export function useConfig() {
return [config, setConfig, isLoading, error];
}
// Correct: explicit interface
interface ConfigState {
config: Config;
setConfig: (c: Config) => void;
isLoading: boolean;
error: Error | null;
}
export function useConfig(): ConfigState { ... }
```
### 9. `useEffect` for derived state
```tsx
// Wrong: effect to compute a value from props
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${prefix} ${name}`);
}, [prefix, name]);
// Correct: derive during render
const fullName = `${prefix} ${name}`;
// Or useMemo if computation is expensive
const fullName = useMemo(() => expensiveFormat(prefix, name), [prefix, name]);
```
### 10. Committing secrets or environment files
Never commit `.env`, `.env.local`, credentials, API keys, or tokens. The `.gitignore` must include these patterns. If a secret is accidentally committed, rotate it immediately -- do not just delete the file.

View File

@@ -0,0 +1,622 @@
# Configuration Guide
> How ArchiTools is configured at build time, runtime, and per-module.
---
## Environment Variables
All environment variables are defined in `.env.local` for development and injected via Docker for production. The `.env.example` file in the repository root documents every variable:
```bash
# =============================================================================
# ArchiTools Environment Configuration
# =============================================================================
# Copy this file to .env.local for development.
# For Docker, pass variables via docker-compose.yml or Portainer.
# =============================================================================
# -----------------------------------------------------------------------------
# Application
# -----------------------------------------------------------------------------
NEXT_PUBLIC_APP_NAME=ArchiTools
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Version displayed in footer/about. Set by CI or manually.
NEXT_PUBLIC_APP_VERSION=0.1.0
# -----------------------------------------------------------------------------
# Storage
# -----------------------------------------------------------------------------
# Active storage adapter: 'localStorage' | 'api'
NEXT_PUBLIC_STORAGE_ADAPTER=localStorage
# REST API storage backend (required when STORAGE_ADAPTER=api)
# STORAGE_API_URL=http://api.internal/storage
# MinIO object storage (server-side only, for file/binary storage)
# MINIO_ENDPOINT=minio.internal:9000
# MINIO_ACCESS_KEY=
# MINIO_SECRET_KEY=
# MINIO_BUCKET=architools
# MINIO_USE_SSL=false
# -----------------------------------------------------------------------------
# Authentication (future — Authentik SSO)
# -----------------------------------------------------------------------------
# AUTHENTIK_URL=https://auth.internal
# AUTHENTIK_CLIENT_ID=
# AUTHENTIK_CLIENT_SECRET=
# NEXTAUTH_URL=http://localhost:3000
# NEXTAUTH_SECRET=
# -----------------------------------------------------------------------------
# Feature Flags
# -----------------------------------------------------------------------------
# Comma-separated list of flag overrides. Format: flag_name=true/false
# Example: NEXT_PUBLIC_FLAGS_OVERRIDE=module_ai_chat=true,module_password_vault=false
NEXT_PUBLIC_FLAGS_OVERRIDE=
# -----------------------------------------------------------------------------
# External Services
# -----------------------------------------------------------------------------
# N8N webhook endpoint for automation triggers
# N8N_WEBHOOK_URL=https://n8n.internal/webhook
# Gitea API (for potential repo integration)
# GITEA_URL=https://git.internal
# GITEA_TOKEN=
# -----------------------------------------------------------------------------
# External Tool Links (displayed on dashboard)
# -----------------------------------------------------------------------------
# NEXT_PUBLIC_PORTAINER_URL=https://portainer.internal
# NEXT_PUBLIC_DOZZLE_URL=https://dozzle.internal
# NEXT_PUBLIC_NETDATA_URL=https://netdata.internal
# NEXT_PUBLIC_UPTIME_KUMA_URL=https://uptime.internal
# NEXT_PUBLIC_IT_TOOLS_URL=https://it-tools.internal
# NEXT_PUBLIC_STIRLING_PDF_URL=https://pdf.internal
# NEXT_PUBLIC_FILEBROWSER_URL=https://files.internal
# NEXT_PUBLIC_N8N_URL=https://n8n.internal
# NEXT_PUBLIC_GITEA_URL=https://git.internal
# NEXT_PUBLIC_MINIO_CONSOLE_URL=https://minio-console.internal
```
### Naming Rules
| Prefix | Scope | Access |
|---|---|---|
| `NEXT_PUBLIC_` | Client + Server | Bundled into client JS. Never put secrets here. |
| No prefix | Server only | Available in API routes, server components, middleware. |
**Never put credentials, API keys, or secrets in `NEXT_PUBLIC_` variables.** They are embedded in the JavaScript bundle and visible to anyone with browser DevTools.
---
## Module Configuration
**File:** `src/config/modules.ts`
Defines the module registry. Every module in the system is declared here. Modules not in this registry do not exist to the platform.
```typescript
// src/config/modules.ts
import type { LucideIcon } from 'lucide-react';
interface ModuleDefinition {
/** Unique module identifier. Matches the directory name under src/modules/. */
id: string;
/** Romanian display name shown in navigation and headers. */
label: string;
/** Short Romanian description for tooltips and dashboard cards. */
description: string;
/** Lucide icon component reference. */
icon: LucideIcon;
/** URL path segment. Module is accessible at /modules/{path}. */
path: string;
/** Storage namespace. Must match STORAGE-LAYER.md namespace table. */
namespace: string;
/** Feature flag that controls activation. References flags.ts. */
featureFlag: string;
/** Default enabled state when no flag override exists. */
defaultEnabled: boolean;
/** Minimum role required to see this module. */
minRole: Role;
/** Sort order in navigation sidebar. Lower = higher. */
order: number;
/** If true, module is loaded only when navigated to. */
lazy: boolean;
/** Which companies this module is relevant to. Empty = all. */
companies: CompanyId[];
}
export const MODULE_REGISTRY: ModuleDefinition[] = [
{
id: 'dashboard',
label: 'Panou Principal',
description: 'Tablou de bord cu widget-uri și acces rapid',
icon: LayoutDashboard,
path: 'dashboard',
namespace: 'dashboard',
featureFlag: 'module_dashboard',
defaultEnabled: true,
minRole: 'viewer',
order: 0,
lazy: false,
companies: [],
},
{
id: 'registratura',
label: 'Registratură',
description: 'Registru de intrări și ieșiri documente',
icon: BookOpen,
path: 'registratura',
namespace: 'registratura',
featureFlag: 'module_registratura',
defaultEnabled: true,
minRole: 'user',
order: 10,
lazy: true,
companies: [],
},
// ... remaining modules follow same pattern
];
```
### Adding a New Module
1. Create the module directory: `src/modules/{module-id}/`.
2. Add an entry to `MODULE_REGISTRY` in `src/config/modules.ts`.
3. Add a feature flag in `src/config/flags.ts`.
4. Add the namespace to the namespace table in the storage layer docs.
5. Create the App Router page: `src/app/modules/{path}/page.tsx`.
6. The navigation system and feature flag engine pick it up automatically.
---
## Feature Flag Configuration
**File:** `src/config/flags.ts`
```typescript
// src/config/flags.ts
interface FeatureFlag {
/** Flag identifier. Convention: module_{id} for module toggles, feature_{name} for features. */
id: string;
/** Romanian description. */
description: string;
/** Default value when no override exists. */
defaultValue: boolean;
/** If true, flag is only visible in admin settings. */
adminOnly: boolean;
/** If true, flag is experimental and shown with a warning badge. */
experimental: boolean;
}
export const FEATURE_FLAGS: FeatureFlag[] = [
// Module flags
{ id: 'module_dashboard', description: 'Panou principal', defaultValue: true, adminOnly: false, experimental: false },
{ id: 'module_registratura', description: 'Registratură', defaultValue: true, adminOnly: false, experimental: false },
{ id: 'module_email_signature', description: 'Generator semnătură email', defaultValue: true, adminOnly: false, experimental: false },
{ id: 'module_word_xml', description: 'Generatoare Word XML', defaultValue: true, adminOnly: false, experimental: false },
{ id: 'module_digital_signatures', description: 'Semnături și ștampile digitale', defaultValue: true, adminOnly: false, experimental: false },
{ id: 'module_password_vault', description: 'Seif parole partajat', defaultValue: true, adminOnly: true, experimental: false },
{ id: 'module_it_inventory', description: 'Inventar IT', defaultValue: true, adminOnly: false, experimental: false },
{ id: 'module_address_book', description: 'Agendă de contacte', defaultValue: true, adminOnly: false, experimental: false },
{ id: 'module_prompt_generator', description: 'Generator de prompturi AI', defaultValue: true, adminOnly: false, experimental: false },
{ id: 'module_word_templates', description: 'Șabloane Word', defaultValue: true, adminOnly: false, experimental: false },
{ id: 'module_tag_manager', description: 'Manager etichete', defaultValue: true, adminOnly: false, experimental: false },
{ id: 'module_mini_utilities', description: 'Mini utilitare', defaultValue: true, adminOnly: false, experimental: false },
{ id: 'module_ai_chat', description: 'Chat AI', defaultValue: false, adminOnly: false, experimental: true },
// Feature flags (non-module)
{ id: 'feature_dark_mode', description: 'Mod întunecat', defaultValue: true, adminOnly: false, experimental: false },
{ id: 'feature_export_import', description: 'Export/import date', defaultValue: true, adminOnly: true, experimental: false },
{ id: 'feature_cross_tab_sync', description: 'Sincronizare între tab-uri', defaultValue: true, adminOnly: false, experimental: false },
{ id: 'feature_infra_links', description: 'Linkuri infrastructură pe panou',defaultValue: true, adminOnly: false, experimental: false },
];
```
### Flag Resolution Order
1. **Environment override** (`NEXT_PUBLIC_FLAGS_OVERRIDE`): Highest priority. Parsed as comma-separated `key=value` pairs.
2. **Runtime override** (stored in `system` namespace under key `flag-overrides`): Set through admin UI. Persists across sessions.
3. **Default value** (`defaultValue` in `flags.ts`): Lowest priority. Used when no override exists.
```typescript
// src/lib/flags/resolve.ts
function resolveFlag(flagId: string): boolean {
// 1. Environment override
const envOverrides = parseEnvOverrides(process.env.NEXT_PUBLIC_FLAGS_OVERRIDE);
if (flagId in envOverrides) return envOverrides[flagId];
// 2. Runtime override (from storage)
const runtimeOverrides = getRuntimeOverrides(); // from system namespace
if (flagId in runtimeOverrides) return runtimeOverrides[flagId];
// 3. Default
const flag = FEATURE_FLAGS.find(f => f.id === flagId);
return flag?.defaultValue ?? false;
}
```
### Usage in Components
```typescript
import { useFeatureFlag } from '@/lib/flags/hooks';
function SomeComponent() {
const aiChatEnabled = useFeatureFlag('module_ai_chat');
if (!aiChatEnabled) return null;
return <AIChatModule />;
}
```
---
## Navigation Configuration
**File:** `src/config/navigation.ts`
Navigation is derived from the module registry. No separate navigation config is maintained. The navigation system reads `MODULE_REGISTRY`, filters by feature flags and role, sorts by `order`, and renders the sidebar.
```typescript
// src/config/navigation.ts
import { MODULE_REGISTRY } from './modules';
interface NavGroup {
label: string;
modules: string[]; // Module IDs belonging to this group
}
/**
* Groups organize modules in the sidebar.
* Modules not listed in any group appear under "Altele" (Others).
*/
export const NAV_GROUPS: NavGroup[] = [
{
label: 'Principal',
modules: ['dashboard'],
},
{
label: 'Documente',
modules: ['registratura', 'word-templates', 'word-xml', 'email-signature'],
},
{
label: 'Resurse',
modules: ['address-book', 'digital-signatures', 'password-vault', 'it-inventory'],
},
{
label: 'Instrumente',
modules: ['prompt-generator', 'ai-chat', 'mini-utilities'],
},
{
label: 'Administrare',
modules: ['tag-manager'],
},
];
```
### External Links
Infrastructure tool links are configured via environment variables and displayed in a separate sidebar section or dashboard widget:
```typescript
// src/config/navigation.ts
interface ExternalLink {
label: string;
envVar: string; // Environment variable holding the URL
icon: LucideIcon;
category: 'infra' | 'dev' | 'tools';
}
export const EXTERNAL_LINKS: ExternalLink[] = [
{ label: 'Portainer', envVar: 'NEXT_PUBLIC_PORTAINER_URL', icon: Container, category: 'infra' },
{ label: 'Dozzle', envVar: 'NEXT_PUBLIC_DOZZLE_URL', icon: ScrollText, category: 'infra' },
{ label: 'Netdata', envVar: 'NEXT_PUBLIC_NETDATA_URL', icon: Activity, category: 'infra' },
{ label: 'Uptime Kuma', envVar: 'NEXT_PUBLIC_UPTIME_KUMA_URL', icon: HeartPulse, category: 'infra' },
{ label: 'Gitea', envVar: 'NEXT_PUBLIC_GITEA_URL', icon: GitBranch, category: 'dev' },
{ label: 'MinIO', envVar: 'NEXT_PUBLIC_MINIO_CONSOLE_URL', icon: Database, category: 'dev' },
{ label: 'IT-Tools', envVar: 'NEXT_PUBLIC_IT_TOOLS_URL', icon: Wrench, category: 'tools' },
{ label: 'Stirling PDF', envVar: 'NEXT_PUBLIC_STIRLING_PDF_URL', icon: FileText, category: 'tools' },
{ label: 'N8N', envVar: 'NEXT_PUBLIC_N8N_URL', icon: Workflow, category: 'tools' },
{ label: 'Filebrowser', envVar: 'NEXT_PUBLIC_FILEBROWSER_URL', icon: FolderOpen, category: 'tools' },
];
```
Links with no URL configured (empty env var) are hidden automatically.
---
## Company Configuration
**File:** `src/config/companies.ts`
Static company data. Updated rarely and only by developers.
```typescript
// src/config/companies.ts
import type { CompanyId, Company } from '@/types/company';
export const COMPANIES: Record<CompanyId, Company> = {
beletage: {
id: 'beletage',
name: 'Beletage SRL',
shortName: 'Beletage',
cui: 'RO12345678', // replace with real CUI
address: '...',
email: 'office@beletage.ro',
phone: '...',
},
'urban-switch': {
id: 'urban-switch',
name: 'Urban Switch SRL',
shortName: 'Urban Switch',
cui: 'RO23456789',
address: '...',
email: 'office@urbanswitch.ro',
phone: '...',
},
'studii-de-teren': {
id: 'studii-de-teren',
name: 'Studii de Teren SRL',
shortName: 'Studii de Teren',
cui: 'RO34567890',
address: '...',
email: 'office@studiideteren.ro',
phone: '...',
},
group: {
id: 'group',
name: 'Grup Beletage',
shortName: 'Grup',
cui: '',
},
};
export const COMPANY_IDS: CompanyId[] = ['beletage', 'urban-switch', 'studii-de-teren'];
export const ALL_COMPANY_IDS: CompanyId[] = [...COMPANY_IDS, 'group'];
```
`COMPANY_IDS` excludes `'group'` for UI dropdowns where a real company selection is required. `ALL_COMPANY_IDS` includes it for contexts where "all companies" is valid.
---
## Theme Configuration
**File:** `src/config/theme.ts`
Theme tokens extend the shadcn/ui and Tailwind defaults.
```typescript
// src/config/theme.ts
export const THEME_CONFIG = {
/** Default theme on first visit. User preference is stored in system namespace. */
defaultTheme: 'light' as 'light' | 'dark' | 'system',
/** Company brand colors for badges, indicators, and charts. */
companyColors: {
beletage: { primary: '#1E3A5F', accent: '#4A90D9' },
'urban-switch': { primary: '#2D5F3E', accent: '#6BBF8A' },
'studii-de-teren': { primary: '#5F4B1E', accent: '#D9A44A' },
group: { primary: '#374151', accent: '#6B7280' },
},
/** Tag category default colors. */
tagCategoryColors: {
project: '#3B82F6',
client: '#8B5CF6',
phase: '#F59E0B',
type: '#10B981',
priority: '#EF4444',
domain: '#6366F1',
custom: '#6B7280',
},
} as const;
```
Theme switching is handled by `next-themes` (shadcn/ui standard). The user's preference is stored in the `system` storage namespace under the key `theme-preference`.
### Tailwind Integration
Company colors and tag colors are registered in `tailwind.config.ts` as extended colors:
```typescript
// tailwind.config.ts (relevant excerpt)
theme: {
extend: {
colors: {
beletage: { DEFAULT: '#1E3A5F', accent: '#4A90D9' },
urbanswitch: { DEFAULT: '#2D5F3E', accent: '#6BBF8A' },
studiideteren: { DEFAULT: '#5F4B1E', accent: '#D9A44A' },
},
},
},
```
---
## Build-Time vs Runtime Configuration
| Configuration Type | When Resolved | How Set | Changeable Without Rebuild |
|---|---|---|---|
| `NEXT_PUBLIC_*` env vars | Build time (bundled into JS) | `.env.local`, Docker build args | No (requires rebuild) |
| Server-only env vars | Runtime (read on each request) | Docker env vars, `.env.local` | Yes |
| Feature flag defaults | Build time (in `flags.ts`) | Source code | No |
| Feature flag overrides (env) | Build time (via `NEXT_PUBLIC_FLAGS_OVERRIDE`) | Env var | No |
| Feature flag overrides (runtime) | Runtime (from storage) | Admin UI | Yes |
| Module registry | Build time (in `modules.ts`) | Source code | No |
| Company data | Build time (in `companies.ts`) | Source code | No |
| Theme preference | Runtime (from storage) | User toggle | Yes |
| External tool URLs | Build time (via `NEXT_PUBLIC_*`) | Env vars | No |
### Making `NEXT_PUBLIC_` Variables Runtime-Configurable in Docker
Next.js inlines `NEXT_PUBLIC_` variables at build time, which is problematic for Docker images that should be configurable at deploy time. Solution:
**1. Build with placeholder values:**
```dockerfile
# Dockerfile
ARG NEXT_PUBLIC_APP_URL=__NEXT_PUBLIC_APP_URL__
ARG NEXT_PUBLIC_STORAGE_ADAPTER=__NEXT_PUBLIC_STORAGE_ADAPTER__
```
**2. Replace at container start:**
```bash
#!/bin/sh
# docker/entrypoint.sh
# Replace build-time placeholders with runtime environment values
find /app/.next -type f -name '*.js' | while read file; do
sed -i "s|__NEXT_PUBLIC_APP_URL__|${NEXT_PUBLIC_APP_URL:-http://localhost:3000}|g" "$file"
sed -i "s|__NEXT_PUBLIC_STORAGE_ADAPTER__|${NEXT_PUBLIC_STORAGE_ADAPTER:-localStorage}|g" "$file"
# ... repeat for each NEXT_PUBLIC_ variable
done
exec node server.js
```
**3. Use in docker-compose:**
```yaml
# docker-compose.yml
services:
architools:
image: architools:latest
environment:
- NEXT_PUBLIC_APP_URL=https://tools.internal
- NEXT_PUBLIC_STORAGE_ADAPTER=api
- STORAGE_API_URL=http://api:8080/storage
- MINIO_ENDPOINT=minio:9000
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
ports:
- "3000:3000"
```
---
## Docker Environment Injection
### Development
```bash
# .env.local (gitignored)
NEXT_PUBLIC_APP_NAME=ArchiTools
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_STORAGE_ADAPTER=localStorage
```
### Production (Docker Compose)
```yaml
# docker-compose.yml
version: '3.8'
services:
architools:
build:
context: .
dockerfile: Dockerfile
container_name: architools
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- NEXT_PUBLIC_APP_NAME=ArchiTools
- NEXT_PUBLIC_APP_URL=https://tools.internal
- NEXT_PUBLIC_STORAGE_ADAPTER=api
- STORAGE_API_URL=http://api:8080/storage
- MINIO_ENDPOINT=minio:9000
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
- MINIO_BUCKET=architools
# External links
- NEXT_PUBLIC_PORTAINER_URL=https://portainer.internal
- NEXT_PUBLIC_GITEA_URL=https://git.internal
- NEXT_PUBLIC_N8N_URL=https://n8n.internal
networks:
- internal
networks:
internal:
external: true
```
### Production (Portainer)
When deploying via Portainer, set environment variables in the container's Environment section. Sensitive values (MinIO keys, auth secrets) should use Portainer's secrets management rather than plain environment variables.
---
## Configuration Precedence
Resolution order from lowest to highest priority:
```
1. Source code defaults (flags.ts, modules.ts, companies.ts, theme.ts)
↑ overridden by
2. Environment variables (.env.local / Docker env)
↑ overridden by
3. Runtime overrides (admin UI → stored in system namespace)
```
If a conflict exists, the higher-priority source wins. Runtime overrides are only available for feature flags and user preferences (theme, sidebar state). Structural configuration (module registry, company data, navigation groups) is not runtime-overridable; it requires a code change and rebuild.
---
## Configuration File Index
| File | Purpose | Changeable at Runtime |
|---|---|---|
| `src/config/modules.ts` | Module registry and metadata | No |
| `src/config/flags.ts` | Feature flag definitions and defaults | Overridable via env/storage |
| `src/config/navigation.ts` | Sidebar groups and external links | No |
| `src/config/companies.ts` | Company master data | No |
| `src/config/theme.ts` | Theme tokens and brand colors | Theme preference only |
| `.env.local` | Development environment variables | N/A (dev only) |
| `.env.example` | Documented variable template (committed) | N/A (reference) |
| `docker-compose.yml` | Production environment variables | At deploy time |
| `docker/entrypoint.sh` | Runtime placeholder replacement | At container start |
---
## Validation
On application startup, the config system validates:
1. All required `NEXT_PUBLIC_` variables are set (not empty or placeholder).
2. `NEXT_PUBLIC_STORAGE_ADAPTER` is a known adapter type.
3. If adapter is `api`, `STORAGE_API_URL` is set.
4. If MinIO is configured, all three of `MINIO_ENDPOINT`, `MINIO_ACCESS_KEY`, and `MINIO_SECRET_KEY` are present.
5. Feature flag overrides parse correctly (no malformed entries).
6. Module IDs in `NAV_GROUPS` reference existing modules in `MODULE_REGISTRY`.
Validation errors are logged to the console with `[ArchiTools Config]` prefix and do not crash the application. Missing optional config results in graceful degradation (e.g., external links not shown, MinIO features unavailable).

View File

@@ -0,0 +1,717 @@
# Docker Deployment Guide
> ArchiTools internal reference -- containerized deployment on the on-premise Ubuntu server.
---
## Overview
ArchiTools runs as a single Docker container behind Nginx Proxy Manager on the internal network. The deployment pipeline is:
```
Developer pushes to Gitea
--> Portainer webhook triggers stack redeploy (or Watchtower detects image change)
--> Docker builds multi-stage image
--> Container starts on port 3000
--> Nginx Proxy Manager routes tools.internal --> localhost:3000
--> Users access via browser
```
The container runs a standalone Next.js production server. No Node.js process manager (PM2, forever) is needed -- the container runtime handles restarts via `restart: unless-stopped`.
---
## Dockerfile
Multi-stage build that produces a minimal production image.
```dockerfile
# Stage 1: Dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
# Stage 2: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Stage 3: Runner
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]
```
### Stage Breakdown
| Stage | Base | Purpose | Output |
|---|---|---|---|
| `deps` | `node:20-alpine` | Install production dependencies only | `node_modules/` |
| `builder` | `node:20-alpine` | Compile TypeScript, build Next.js bundle | `.next/standalone/`, `.next/static/`, `public/` |
| `runner` | `node:20-alpine` | Minimal runtime image with non-root user | Final image (~120 MB) |
### Why Multi-Stage
- The `deps` stage caches `node_modules` independently of source code changes. If only application code changes, Docker reuses the cached dependency layer.
- The `builder` stage contains all dev dependencies and source files but is discarded after the build.
- The `runner` stage contains only the standalone server output, static assets, and public files. No `node_modules` directory, no source code, no dev tooling.
### Security Notes
- The `nextjs` user (UID 1001) is a non-root system user. The container never runs as root.
- Alpine Linux has a minimal attack surface. No shell utilities beyond BusyBox basics.
- The `NODE_ENV=production` flag disables React development warnings, enables Next.js production optimizations, and prevents accidental dev-mode behavior.
---
## next.config.ts Requirements
The standalone output mode is mandatory for the Docker deployment. Without it, Next.js expects the full `node_modules` directory at runtime.
```typescript
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
// Required for Docker: trust the reverse proxy headers
// so that Next.js resolves the correct protocol and host
experimental: {
// If needed in future Next.js versions
},
};
export default nextConfig;
```
### What `output: 'standalone'` Does
1. Traces all required Node.js dependencies at build time.
2. Copies only the needed files into `.next/standalone/`.
3. Generates a self-contained `server.js` that starts a production HTTP server.
4. Eliminates the need for `node_modules` in the runtime image.
The standalone output does **not** include the `public/` or `.next/static/` directories. These must be copied explicitly in the Dockerfile (which the Dockerfile above does).
---
## docker-compose.yml
```yaml
version: '3.8'
services:
architools:
build: .
container_name: architools
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- NEXT_PUBLIC_APP_URL=${APP_URL:-http://localhost:3000}
env_file:
- .env
volumes:
- architools-data:/app/data
networks:
- proxy-network
labels:
- "com.centurylinklabs.watchtower.enable=true"
volumes:
architools-data:
networks:
proxy-network:
external: true
```
### Field Reference
| Field | Purpose |
|---|---|
| `build: .` | Build from the Dockerfile in the repository root. |
| `container_name: architools` | Fixed name for predictable Portainer/Dozzle references. |
| `restart: unless-stopped` | Auto-restart on crash or server reboot. Only stops if explicitly stopped. |
| `ports: "3000:3000"` | Map container port 3000 to host port 3000. Nginx Proxy Manager connects here. |
| `env_file: .env` | Load environment variables from `.env`. Never committed to Gitea. |
| `volumes: architools-data:/app/data` | Persistent volume for future server-side data. Not used in localStorage phase. |
| `networks: proxy-network` | Shared Docker network with Nginx Proxy Manager and other services. |
| `labels: watchtower.enable=true` | Opt in to Watchtower automatic image updates. |
### The `proxy-network` Network
All services that Nginx Proxy Manager routes to must be on the same Docker network. This network is created once and shared across all stacks:
```bash
docker network create proxy-network
```
If the network already exists (it should -- other services like Authentik, MinIO, N8N use it), the `external: true` declaration tells Docker Compose not to create it.
---
## Environment Configuration
### `.env` File
```bash
# ──────────────────────────────────────────
# Application
# ──────────────────────────────────────────
NODE_ENV=production
NEXT_PUBLIC_APP_URL=https://tools.internal
NEXT_PUBLIC_APP_ENV=production
# ──────────────────────────────────────────
# Feature Flags (override defaults from src/config/flags.ts)
# ──────────────────────────────────────────
NEXT_PUBLIC_FLAG_MODULE_REGISTRATURA=true
NEXT_PUBLIC_FLAG_MODULE_PROMPT_GENERATOR=true
NEXT_PUBLIC_FLAG_MODULE_EMAIL_SIGNATURE=true
NEXT_PUBLIC_FLAG_MODULE_AI_CHAT=false
# ──────────────────────────────────────────
# Storage
# ──────────────────────────────────────────
NEXT_PUBLIC_STORAGE_ADAPTER=localStorage
# Future: API backend
# STORAGE_API_URL=http://localhost:4000/api/storage
# Future: MinIO
# MINIO_ENDPOINT=minio.internal
# MINIO_ACCESS_KEY=architools
# MINIO_SECRET_KEY=<secret>
# MINIO_BUCKET=architools
# ──────────────────────────────────────────
# Authentication (future: Authentik SSO)
# ──────────────────────────────────────────
# AUTHENTIK_ISSUER=https://auth.internal
# AUTHENTIK_CLIENT_ID=architools
# AUTHENTIK_CLIENT_SECRET=<secret>
```
### Variable Scoping Rules
| Prefix | Available In | Notes |
|---|---|---|
| `NEXT_PUBLIC_*` | Client + server | Inlined into the JavaScript bundle at build time. Visible to users in browser DevTools. Never put secrets here. |
| No prefix | Server only | Available in API routes, middleware, server components. Used for secrets, credentials, internal URLs. |
### Build-Time vs. Runtime
`NEXT_PUBLIC_*` variables are baked into the bundle during `npm run build`. Changing them requires a rebuild. Non-prefixed variables are read at runtime and can be changed by restarting the container.
For Docker, this means:
- `NEXT_PUBLIC_*` changes require rebuilding the image.
- Server-only variables can be changed via Portainer environment editor and restarting the container.
---
## Nginx Proxy Manager Setup
### Proxy Host Configuration
| Field | Value |
|---|---|
| **Domain Names** | `tools.internal` (or `tools.beletage.internal`, etc.) |
| **Scheme** | `http` |
| **Forward Hostname / IP** | `architools` (Docker container name, resolved via `proxy-network`) |
| **Forward Port** | `3000` |
| **Block Common Exploits** | Enabled |
| **Websockets Support** | Enabled (for HMR in dev; harmless in production) |
### SSL Configuration
**Internal access (self-signed or internal CA):**
1. In Nginx Proxy Manager, go to SSL Certificates > Add SSL Certificate > Custom.
2. Upload the internal CA certificate and key.
3. Assign to the `tools.internal` proxy host.
4. Browsers on internal machines must trust the internal CA (deployed via group policy or manual install).
**External access (Let's Encrypt):**
1. When the domain becomes publicly resolvable (e.g., `tools.beletage.ro`), switch to Let's Encrypt.
2. In Nginx Proxy Manager, go to SSL Certificates > Add SSL Certificate > Let's Encrypt.
3. Enter the domain, email, and agree to ToS.
4. Nginx Proxy Manager handles renewal automatically.
### Security Headers
Add the following in the proxy host's Advanced tab (Custom Nginx Configuration):
```nginx
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Content Security Policy
add_header Content-Security-Policy "
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:;
font-src 'self' data:;
connect-src 'self' https://api.openai.com https://api.anthropic.com;
frame-ancestors 'self';
" always;
```
**Notes on CSP:**
- `'unsafe-inline'` and `'unsafe-eval'` are required by Next.js in production. Tighten with nonces if migrating to a stricter CSP in the future.
- `connect-src` includes AI provider API domains for the AI Chat and Prompt Generator modules. Adjust as providers are added or removed.
- `frame-ancestors 'self'` prevents clickjacking (equivalent to `X-Frame-Options: SAMEORIGIN`).
---
## Portainer Deployment
### Stack Deployment from Gitea
1. In Portainer, go to Stacks > Add Stack.
2. Select **Repository** as the build method.
3. Configure:
| Field | Value |
|---|---|
| **Name** | `architools` |
| **Repository URL** | `https://gitea.internal/beletage/architools.git` |
| **Repository reference** | `refs/heads/main` |
| **Compose path** | `docker-compose.yml` |
| **Authentication** | Gitea access token or SSH key |
4. Under **Environment variables**, add all variables from the `.env` file. Portainer stores these securely and injects them at deploy time.
5. Enable **Auto update** with a webhook if desired.
### Environment Variable Management
Portainer provides a UI for managing environment variables per stack. Use this for:
- Toggling feature flags without touching the repository.
- Updating server-side secrets (MinIO keys, Authentik credentials) without rebuilding.
- Switching `NEXT_PUBLIC_*` values (requires stack redeploy to rebuild the image).
**Important:** `NEXT_PUBLIC_*` variables are build-time constants. Changing them in Portainer requires redeploying the stack (which triggers a rebuild), not just restarting the container.
### Container Monitoring
Portainer provides:
- **Container status:** running, stopped, restarting.
- **Resource usage:** CPU, memory, network I/O.
- **Logs:** stdout/stderr output (same as Dozzle, but accessible from the Portainer UI).
- **Console:** exec into the container for debugging (use sparingly; the container has minimal tooling).
- **Restart/Stop/Remove:** Manual container lifecycle controls.
---
## Watchtower Integration
Watchtower monitors Docker containers and automatically updates them when a new image is available.
### How It Works with ArchiTools
1. The `docker-compose.yml` includes the label `com.centurylinklabs.watchtower.enable=true`.
2. Watchtower periodically checks (default: every 24 hours, configurable) if the image has changed.
3. If a new image is detected, Watchtower:
- Pulls the new image.
- Stops the running container.
- Creates a new container with the same configuration.
- Starts the new container.
- Removes the old image (if configured).
### Triggering Updates
**Automatic (Watchtower polling):** Watchtower polls at a configured interval. Suitable for non-urgent updates.
**Manual (Portainer):** Redeploy the stack from Portainer. This pulls the latest code from Gitea, rebuilds the image, and restarts the container.
**Webhook (Portainer):** Configure a Portainer webhook URL. Add it as a webhook in Gitea (triggered on push to `main`). Gitea pushes, Portainer receives the webhook, and redeploys.
### Recommended Flow
For ArchiTools, the primary deployment trigger is the **Portainer webhook from Gitea**:
```
git push origin main
--> Gitea fires webhook to Portainer
--> Portainer redeploys the architools stack
--> Docker rebuilds the image (multi-stage build)
--> New container starts
--> Old container removed
```
Watchtower serves as a safety net for cases where the webhook fails or for updating the base `node:20-alpine` image.
---
## Health Check Endpoint
The application exposes a health check endpoint at `/api/health`.
```typescript
// src/app/api/health/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json(
{
status: 'healthy',
timestamp: new Date().toISOString(),
version: process.env.npm_package_version ?? 'unknown',
environment: process.env.NODE_ENV ?? 'unknown',
},
{ status: 200 }
);
}
```
### Usage
- **Uptime Kuma:** Add a monitor with type HTTP(s), URL `http://architools:3000/api/health`, expected status code `200`. Monitor interval: 60 seconds.
- **Docker health check (optional):** Add to `docker-compose.yml`:
```yaml
services:
architools:
# ... existing config ...
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
```
The `start_period` gives Next.js time to start before Docker begins health checking.
---
## Logging
### Strategy
ArchiTools logs to stdout and stderr. No file-based logging, no log rotation configuration inside the container. Docker captures all stdout/stderr output and makes it available via:
- **Dozzle:** Real-time log viewer. Access at `dozzle.internal`. Filter by container name `architools`.
- **Portainer:** Logs tab on the container detail page.
- **CLI:** `docker logs architools` or `docker logs -f architools` for live tail.
### Log Levels
| Source | Output | Captured By |
|---|---|---|
| Next.js server | Request logs, compilation warnings | stdout |
| Application `console.log` | Debug information, state changes | stdout |
| Application `console.error` | Errors, stack traces | stderr |
| Unhandled exceptions | Crash traces | stderr |
### Structured Logging (Future)
When the application grows beyond simple console output, adopt a structured JSON logger (e.g., `pino`). This enables Dozzle or a future log aggregator to parse, filter, and search log entries by level, module, and context.
---
## Data Persistence Strategy
### Current Phase: localStorage
In the current phase, all module data lives in the browser's `localStorage`. The Docker container is stateless -- no server-side data storage. This means:
- **No data loss on container restart.** Data is in the browser, not the container.
- **No backup needed for the container.** The volume mount (`architools-data:/app/data`) is provisioned but empty.
- **No multi-user data sharing.** Each browser has its own isolated data set.
- **Export/import is the backup mechanism.** Modules provide export buttons that download JSON files.
### Future Phase: Server-Side Storage
When the storage adapter switches to `api` or a database backend:
| Concern | Implementation |
|---|---|
| **Database** | PostgreSQL container on the same Docker network. Volume-mounted for persistence. |
| **File storage** | MinIO (already running). ArchiTools stores file references in the database, binary objects in MinIO buckets. |
| **Backup** | Database dumps + MinIO bucket sync. Scheduled via N8N or cron. |
| **Volume mount** | `architools-data:/app/data` used for SQLite (if chosen as interim DB) or temp files. |
### Volume Mount
The `architools-data` volume is defined in `docker-compose.yml` and mounted at `/app/data`. It persists across container restarts and image rebuilds. Currently unused but ready for:
- SQLite database file (interim before PostgreSQL).
- Temporary file processing (document generation, PDF manipulation).
- Cache files if needed.
---
## Build and Deploy Workflow
### Full Lifecycle
```
1. Developer pushes to Gitea (main branch)
|
2. Gitea fires webhook to Portainer
|
3. Portainer pulls latest code from Gitea repository
|
4. Docker builds multi-stage image:
a. Stage 1 (deps): npm ci --only=production
b. Stage 2 (builder): npm run build (Next.js standalone)
c. Stage 3 (runner): minimal image with server.js
|
5. Portainer stops the running container
|
6. Portainer starts a new container from the fresh image
|
7. Health check passes (GET /api/health returns 200)
|
8. Nginx Proxy Manager routes traffic to the new container
|
9. Uptime Kuma confirms service is up
|
10. Old image is cleaned up (Watchtower or manual docker image prune)
```
### Build Time Expectations
| Stage | Typical Duration | Notes |
|---|---|---|
| `deps` (cached) | <5 seconds | Only re-runs if `package.json` or `package-lock.json` changes. |
| `deps` (fresh) | 30--60 seconds | Full `npm ci` with all dependencies. |
| `builder` | 30--90 seconds | Next.js build. Depends on module count and TypeScript compilation. |
| `runner` | <5 seconds | Just file copies. |
| **Total (cached deps)** | ~1--2 minutes | Typical deployment time. |
| **Total (fresh)** | ~2--3 minutes | After dependency changes. |
### Rollback
If a deployment introduces a bug:
1. In Portainer, stop the current container.
2. Redeploy the stack pointing to the previous Gitea commit (change the repository reference to a specific commit SHA or tag).
3. Alternatively, if the previous Docker image is still cached locally, restart the container from that image.
Tagging releases in Gitea (`v1.0.0`, `v1.1.0`) makes rollback straightforward.
---
## Development vs. Production Configuration
### Comparison
| Aspect | Development | Production |
|---|---|---|
| **Command** | `npm run dev` | `node server.js` (standalone) |
| **Hot reload** | Yes (Fast Refresh) | No |
| **Source maps** | Full | Minimal (production build) |
| **NODE_ENV** | `development` | `production` |
| **Storage adapter** | `localStorage` | `localStorage` (current), `api` (future) |
| **Feature flags** | All enabled for testing | Selective per `.env` |
| **Error display** | Full stack traces in browser | Generic error page |
| **CSP headers** | None (permissive) | Strict (via Nginx Proxy Manager) |
| **SSL** | None (`http://localhost:3000`) | Terminated at Nginx Proxy Manager |
| **Docker** | Not used (direct `npm run dev`) | Multi-stage build, containerized |
| **Port** | 3000 (direct) | 3000 (container) --> 443 (Nginx) |
### Running Development Locally
```bash
# Install dependencies
npm install
# Start dev server
npm run dev
# Access at http://localhost:3000
```
No Docker, no Nginx, no SSL. Just the Next.js dev server.
### Testing Production Build Locally
```bash
# Build the production bundle
npm run build
# Start the production server
npm start
# Or test the Docker build
docker build -t architools:local .
docker run -p 3000:3000 --env-file .env architools:local
```
---
## Troubleshooting
### Container Fails to Start
**Symptom:** Container status shows `Restarting` in Portainer, or `docker ps` shows restart loop.
**Diagnosis:**
```bash
docker logs architools
```
**Common causes:**
| Error | Cause | Fix |
|---|---|---|
| `Error: Cannot find module './server.js'` | `output: 'standalone'` missing from `next.config.ts` | Add `output: 'standalone'` and rebuild. |
| `EACCES: permission denied` | File ownership mismatch | Verify the Dockerfile copies files before switching to `USER nextjs`. |
| `EADDRINUSE: port 3000` | Another container using port 3000 | Change the host port mapping in `docker-compose.yml` (e.g., `"3001:3000"`). |
| `MODULE_NOT_FOUND` | Dependency not in production deps | Move the dependency from `devDependencies` to `dependencies` in `package.json`. |
### Build Fails at `npm run build`
**Symptom:** Docker build exits at the `builder` stage.
**Common causes:**
| Error | Cause | Fix |
|---|---|---|
| TypeScript errors | Type mismatches in code | Fix TypeScript errors locally before pushing. |
| `ENOMEM` | Not enough memory for build | Increase Docker memory limit (Next.js build can use 1--2 GB). |
| Missing environment variables | `NEXT_PUBLIC_*` required at build time | Pass build args or set defaults in `next.config.ts`. |
### Application Returns 502 via Nginx
**Symptom:** Browser shows `502 Bad Gateway`.
**Checklist:**
1. Is the container running? `docker ps | grep architools`
2. Is the container healthy? `docker inspect architools | grep Health`
3. Can Nginx reach the container? Both must be on `proxy-network`.
4. Is the forward port correct (3000)?
5. Is the scheme `http` (not `https` -- SSL terminates at Nginx)?
### Static Assets Not Loading (CSS, JS, Images)
**Symptom:** Page loads but unstyled, or browser console shows 404 for `/_next/static/*`.
**Cause:** Missing `COPY --from=builder /app/.next/static ./.next/static` in the Dockerfile.
**Fix:** Verify both `public/` and `.next/static/` are copied in the runner stage.
### Environment Variables Not Taking Effect
**Symptom:** Feature flag change in Portainer does not change behavior.
**Diagnosis:**
- If the variable starts with `NEXT_PUBLIC_*`: it is baked in at build time. You must redeploy (rebuild the image), not just restart.
- If the variable has no prefix: restart the container. The value is read at runtime.
### High Memory Usage
**Symptom:** Container uses more than expected memory (check Portainer or Netdata).
**Typical usage:** 100--200 MB for a standalone Next.js server with moderate traffic.
**If higher:**
- Check for memory leaks in server-side code (API routes, middleware).
- Set a memory limit in `docker-compose.yml`:
```yaml
services:
architools:
# ... existing config ...
deploy:
resources:
limits:
memory: 512M
```
### Logs Not Appearing in Dozzle
**Symptom:** Dozzle shows the container but no log output.
**Checklist:**
1. Is the container actually running (not in a restart loop)?
2. Is the application writing to stdout/stderr (not to a file)?
3. Is Dozzle configured to monitor all containers on the Docker socket?
### Container Networking Issues
**Symptom:** Container cannot reach other services (MinIO, Authentik, N8N).
**Checklist:**
1. All services must be on the same Docker network (`proxy-network`).
2. Use container names as hostnames (e.g., `http://minio:9000`), not `localhost`.
3. Verify DNS resolution: `docker exec architools wget -q -O- http://minio:9000/minio/health/live`
---
## Quick Reference
### Commands
```bash
# Build image
docker build -t architools .
# Run container
docker run -d --name architools -p 3000:3000 --env-file .env architools
# View logs
docker logs -f architools
# Exec into container
docker exec -it architools sh
# Rebuild and restart (compose)
docker compose down && docker compose up -d --build
# Check health
curl http://localhost:3000/api/health
# Prune old images
docker image prune -f
```
### File Checklist
| File | Required | Purpose |
|---|---|---|
| `Dockerfile` | Yes | Multi-stage build definition. |
| `docker-compose.yml` | Yes | Service orchestration, networking, volumes. |
| `.env` | Yes (not committed) | Environment variables. |
| `.dockerignore` | Recommended | Exclude `node_modules`, `.git`, `.next` from build context. |
| `next.config.ts` | Yes | Must include `output: 'standalone'`. |
| `src/app/api/health/route.ts` | Yes | Health check endpoint. |
### `.dockerignore`
```
node_modules
.next
.git
.gitignore
*.md
docs/
.env
.env.*
```
This reduces the Docker build context size and prevents leaking sensitive files into the image.

View File

@@ -0,0 +1,620 @@
# HTML Tool Integration Guide
How to migrate existing standalone HTML tools into React modules within the ArchiTools dashboard.
---
## Overview
ArchiTools currently has four standalone HTML files that implement useful internal tools:
| File | Purpose |
|---|---|
| `emailsignature/emailsignature-config.html` | Email signature configurator with live preview, color pickers, layout sliders, HTML export |
| `wordXMLgenerator/word-xml-generator-basic.html` | Simple Word XML Custom Part generator |
| `wordXMLgenerator/word-xml-generator-medium.html` | Extended version with Short/Upper/Lower/Initials/First field variants |
| `wordXMLgenerator/word-xml-generator-advanced.html` | Full version with categories, localStorage, simple/advanced mode, POT/CUT metrics, ZIP export |
### Why Integrate
Standalone HTML files work, but they cannot:
- Share a consistent UI theme (dark/light toggle, company branding).
- Use shared storage abstraction (configurations saved in one tool are invisible to another).
- Participate in feature flags or access control.
- Link to related data (e.g., an XML template referencing a project tag).
- Provide a unified navigation experience.
- Be tested with standard tooling (Jest, Playwright).
Integration brings these tools into the dashboard shell with shared infrastructure while preserving all existing functionality.
---
## Migration Strategy
Migration happens in three phases. Each phase produces a working state -- there is no "big bang" cutover.
### Phase 1: Embed (Temporary Bridge)
Wrap the existing HTML file in an `<iframe>` inside a dashboard page. This gives immediate navigation integration with zero rewrite.
```tsx
// src/modules/email-signature/pages/email-signature-page.tsx (Phase 1 only)
export function EmailSignaturePage() {
return (
<div className="h-[calc(100vh-3.5rem)]">
<iframe
src="/legacy/emailsignature-config.html"
className="w-full h-full border-0"
title="Email Signature Configurator"
/>
</div>
);
}
```
**When to use Phase 1:**
- When a tool needs to appear in the sidebar immediately but rewrite resources are not available.
- As a fallback during Phase 2 development (iframe stays live while React version is being built).
**Limitations:**
- No theme synchronization (iframe has its own styles).
- No shared state between iframe and parent.
- No storage abstraction.
- Content Security Policy may block certain CDN scripts.
**Phase 1 is not recommended as a long-term solution.** Move to Phase 2 as soon as practical.
### Phase 2: Extract
Pull JavaScript logic out of the HTML files into typed TypeScript hooks and utility functions. Build a React UI that replaces the HTML structure.
This is where the bulk of the work happens. The goal is functional parity with the original tool, running inside the React component tree with proper state management.
Detailed extraction plans for each tool are in the sections below.
### Phase 3: Normalize
With React UI in place, integrate with platform-level features:
- **Storage abstraction**: Save/load configurations through the shared storage layer instead of raw `localStorage`.
- **Theming**: All colors respond to dark/light toggle and company accent.
- **Tagging**: Link generated artifacts to project tags from the tag manager.
- **Feature flags**: Gate experimental features behind flags.
- **Company context**: Tool behavior adapts to the selected company (logo, colors, address, namespace).
- **Cross-module linking**: An XML template can reference a project; a signature config can link to a company profile.
---
## Email Signature Generator -- Migration Plan
### Current State Analysis
The existing `emailsignature-config.html` contains:
1. **Data inputs**: prefix, name, title, phone (4 text fields).
2. **Color picker system**: 7 color targets (prefix, name, title, address, phone, website, motto) with 4 swatch options each (Beletage brand palette).
3. **Layout sliders**: 8 range inputs controlling pixel-level spacing (green line width, section spacing, logo spacing, title spacing, gutter alignment, icon-text spacing, icon vertical position, motto spacing).
4. **Options**: 3 checkboxes (reply variant, super-reply variant, SVG images).
5. **Signature HTML template builder**: `generateSignatureHTML(data)` function producing a table-based email signature.
6. **Phone formatter**: Converts `07xxxxxxxx` to `+40 xxx xxx xxx` format.
7. **Live preview**: Real-time DOM update on any input change.
8. **Export**: Downloads the signature HTML as a file.
9. **Zoom toggle**: 100%/200% preview scaling.
10. **Collapsible sections**: Manual accordion implementation.
Everything is Beletage-specific: logo URL, address, website, motto, brand colors.
### Extraction Plan
#### Hook: `useSignatureBuilder`
Encapsulates the signature generation logic.
```
Source: generateSignatureHTML() function (lines 280-361)
Target: src/modules/email-signature/hooks/use-signature-builder.ts
Responsibilities:
- Accept SignatureConfig object (all field values, colors, spacing, variant flags)
- Return generated HTML string
- Return structured SignatureData for preview rendering
- Pure computation, no side effects
Interface:
Input: SignatureConfig (typed object with all config fields)
Output: { html: string; previewData: SignatureData }
```
#### Hook: `useSignatureExport`
Handles file download and clipboard copy.
```
Source: export button click handler (lines 441-450)
Target: src/modules/email-signature/hooks/use-signature-export.ts
Responsibilities:
- Generate Blob from HTML string
- Trigger file download with appropriate filename
- Copy HTML to clipboard
- Filename includes company name and date
Interface:
Input: { html: string; companySlug: string }
Output: { downloadHtml: () => void; copyToClipboard: () => Promise<void> }
```
#### Utility: `formatPhoneNumber`
```
Source: phone formatting logic in updatePreview() (lines 365-372)
Target: src/shared/utils/format-phone.ts
Responsibilities:
- Accept raw phone string
- Detect Romanian mobile format (07xxxxxxxx)
- Return { display: string; tel: string } with formatted display and tel: link
This is a shared utility, not signature-specific.
```
#### React UI Replacements
| Original | React Replacement |
|---|---|
| Text inputs with `document.getElementById` | Controlled `<Input>` components with React state |
| Color swatch grid with DOM event delegation | `<ColorPicker>` component with `useState` |
| Range inputs with manual value display | `<Slider>` (shadcn/ui) with value label |
| Collapsible sections | `<Collapsible>` (shadcn/ui) |
| Checkboxes | `<Switch>` or `<Checkbox>` (shadcn/ui) |
| Live preview via `innerHTML` | React component rendering signature structure |
| `alert()`/`confirm()` | `<AlertDialog>` (shadcn/ui) |
| File download via DOM | `useSignatureExport` hook |
#### Generalization: Multi-Company Support
The current tool is hardcoded for Beletage. The React version must support all three companies.
```ts
// src/config/companies.ts
export interface CompanyProfile {
id: string;
name: string;
slug: string;
accent: string; // hex color
logo: {
png: string;
svg: string;
};
address: {
street: string;
city: string;
county: string;
postalCode: string;
country: string;
mapsUrl: string;
};
website: string;
motto: string;
brandColors: Record<string, string>; // named palette
signatureIcons: {
greySlash: { png: string; svg: string };
greenSlash: { png: string; svg: string };
};
}
```
The company selector in the header drives `CompanyProfile` into context. The signature builder reads from this context to populate logo, address, website, motto, and available colors.
#### New: Storage Integration
Save and load signature configurations via the storage abstraction:
```ts
// Signature configs are stored as:
{
id: string;
companyId: string;
name: string; // e.g., "Marius TARAU - Beletage"
config: SignatureConfig; // all field values
createdAt: string;
updatedAt: string;
}
```
Users can save multiple configs (one per person/company combo), load previous configs, and delete old ones.
### File Structure
```
src/modules/email-signature/
pages/
email-signature-page.tsx -- Main page component
components/
signature-form.tsx -- Config form (inputs, colors, sliders)
signature-preview.tsx -- Live preview panel
signature-color-picker.tsx -- Color swatch selector
saved-configs-list.tsx -- List of saved configurations
hooks/
use-signature-builder.ts -- HTML generation logic
use-signature-export.ts -- Download/copy logic
use-signature-config.ts -- State management for all config fields
types.ts -- SignatureConfig, SignatureData interfaces
```
---
## Word XML Generators -- Migration Plan (Consolidate 3 into 1)
### Current State Analysis
Three separate HTML files with increasing complexity:
**Basic** (`word-xml-generator-basic.html`):
- Namespace URI, root element name, field list (textarea).
- Generates XML with one element per field.
- Generates XPath list.
- Copy to clipboard, download XML, demo fill.
- Field name sanitization (spaces to underscores, invalid chars removed, dedup).
**Medium** (`word-xml-generator-medium.html`):
- Same as basic, but generates 6 variants per field: base, Short, Upper, Lower, Initials, First.
- `initials()` helper function.
**Advanced** (`word-xml-generator-advanced.html`):
- Category-based organization (Beneficiar, Proiect, Suprafete, Meta) with default presets.
- Per-category namespace (base namespace + `/CategoryName`).
- Per-category root element (`CategoryNameData`).
- Simple/Advanced mode toggle (advanced adds the 6 variants).
- POT/CUT metric computation for "Suprafete" category.
- localStorage persistence for category data.
- Category management (add, delete, reset to preset, clear).
- Single-category XML download.
- ZIP export of all categories via JSZip.
- Pill-based category selector UI.
### Consolidation Strategy
The three tools become a single React module with a complexity toggle:
| Mode | Equivalent to | Behavior |
|---|---|---|
| **Simplu** | basic | One element per field, no variants |
| **Avansat** | advanced | Categories, variants, POT/CUT, ZIP |
The medium version is subsumed by the advanced mode -- it was an intermediate step, not a distinct use case.
### Extraction Plan
#### Hook: `useXmlGenerator`
Core XML generation logic.
```
Source: generateXML() from basic, generateCategory() from advanced
Target: src/modules/xml-generator/hooks/use-xml-generator.ts
Responsibilities:
- Accept field list, namespace, root element name, mode (simple/advanced)
- Generate XML string for a Custom XML Part
- Generate XPath listing
- Handle variant generation (Short, Upper, Lower, Initials, First)
- Handle metric fields (POT/CUT) when category is surface-related
Interface:
Input: XmlGeneratorInput { fields: string[]; namespace: string; rootElement: string; mode: 'simple' | 'advanced'; computeMetrics: boolean; categoryName?: string }
Output: XmlGeneratorResult { xml: string; xpaths: string; fieldCount: number }
```
#### Hook: `useCategoryManager`
Category CRUD and persistence.
```
Source: initCategories(), switchCategory(), addCategoryPrompt(), etc. from advanced
Target: src/modules/xml-generator/hooks/use-category-manager.ts
Responsibilities:
- Manage list of categories with their field text
- Track active category
- Provide CRUD operations (add, delete, rename, reset to preset, clear)
- Persist to storage abstraction (not raw localStorage)
- Load default presets on first use
Interface:
Input: StorageAdapter
Output: { categories: Category[]; activeCategory: string; addCategory, deleteCategory, ... }
```
#### Pure Function: `sanitizeXmlName`
```
Source: sanitizeName() / sanitizeXmlName() (present in all three files)
Target: src/modules/xml-generator/utils/sanitize-xml-name.ts
Responsibilities:
- Trim whitespace
- Replace spaces with underscores
- Remove invalid XML name characters
- Ensure name starts with letter or underscore
- Return null for empty input
Easily unit-tested in isolation.
```
#### Pure Function: `generateFieldVariants`
```
Source: variant generation logic in medium and advanced
Target: src/modules/xml-generator/utils/generate-field-variants.ts
Responsibilities:
- Given a base field name, return array of variant names
- Variants: base, baseShort, baseUpper, baseLower, baseInitials, baseFirst
- Deduplication against a provided Set of used names
```
#### Pure Function: `generateXpaths`
```
Source: XPath string building in all three files
Target: src/modules/xml-generator/utils/generate-xpaths.ts
Responsibilities:
- Given root element and field list (with variants), produce formatted XPath listing
- Include namespace and root info in header
```
#### Service: `zipExportService`
```
Source: downloadZipAll() from advanced
Target: src/modules/xml-generator/services/zip-export-service.ts
Responsibilities:
- Accept map of { filename: xmlContent }
- Use JSZip to create archive
- Return Blob for download
Dependency: jszip (npm package, replaces CDN script tag)
```
### React UI Replacements
| Original | React Replacement |
|---|---|
| Textarea for field list | Controlled `<Textarea>` with React state |
| Pill-based category selector | `<Tabs>` (shadcn/ui) or custom pill component |
| Simple/Advanced pill toggle | `<Tabs>` with two items |
| `prompt()` for new category name | `<Dialog>` with `<Input>` |
| `confirm()` for deletion | `<AlertDialog>` |
| `alert()` for validation | Toast notification (shadcn/ui `sonner`) |
| `<pre>` for XML output | `<pre>` with syntax highlighting (optional) + copy button |
| Direct `localStorage` | Storage abstraction via hook |
| JSZip CDN | `jszip` npm package |
### New Features in React Version
1. **Template presets per company**: Each company can have its own default categories and fields. Driven by company context.
2. **Save/load configurations**: Named configurations stored via storage abstraction. Users can maintain multiple XML schemas.
3. **Project tag linking**: When generating XML for a specific project, link the configuration to a project tag from the tag manager.
4. **Copy individual XPaths**: Click-to-copy on each XPath line, not just the whole block.
### File Structure
```
src/modules/xml-generator/
pages/
xml-generator-page.tsx -- Main page component
components/
xml-generator-form.tsx -- Namespace, root, mode controls
category-manager.tsx -- Category tabs/pills + CRUD
field-editor.tsx -- Textarea for field list
xml-preview.tsx -- XML output with copy/download
xpath-preview.tsx -- XPath output with copy
saved-configs-list.tsx -- Saved configuration browser
hooks/
use-xml-generator.ts -- XML generation logic
use-category-manager.ts -- Category state management
use-xml-export.ts -- Download/ZIP export
services/
zip-export-service.ts -- JSZip wrapper
utils/
sanitize-xml-name.ts -- Field name sanitization
generate-field-variants.ts -- Variant name generation
generate-xpaths.ts -- XPath string builder
data/
default-presets.ts -- Default category presets
types.ts -- Category, XmlGeneratorInput, etc.
```
---
## General Extraction Patterns
### DOM Manipulation to React State
| HTML/JS Pattern | React Equivalent |
|---|---|
| `document.getElementById('x').value` | `const [x, setX] = useState('')` + controlled input |
| `element.innerHTML = html` | JSX return with variables |
| `element.textContent = text` | `{text}` in JSX |
| `element.classList.toggle('active')` | Conditional className: `cn('pill', isActive && 'active')` |
| `element.style.backgroundColor = color` | `style={{ backgroundColor: color }}` or Tailwind class |
| `element.addEventListener('input', handler)` | `onChange={handler}` on element |
| `document.createElement('div')` | JSX element or `.map()` rendering |
| `parent.appendChild(child)` | Render in JSX, controlled by state array |
| `element.disabled = true` | `disabled={condition}` prop |
### Refs
Use `useRef` only when React state is insufficient:
- Measuring DOM element dimensions.
- Integrating with third-party DOM libraries.
- Storing mutable values that should not trigger re-render.
Do not use `useRef` as a replacement for `document.getElementById`. That pattern belongs in controlled component state.
### Storage
| HTML Pattern | React Equivalent |
|---|---|
| `localStorage.getItem('key')` | `storageAdapter.get<T>('key')` |
| `localStorage.setItem('key', JSON.stringify(data))` | `storageAdapter.set('key', data)` |
| `JSON.parse(localStorage.getItem('key'))` | `storageAdapter.get<T>('key')` (typed, handles parse errors) |
The storage abstraction (`src/core/storage/`) wraps localStorage with:
- Typed get/set.
- JSON serialization.
- Error handling (quota exceeded, parse failures).
- Key namespacing by module.
- Future: swap backend to IndexedDB or API without changing module code.
### Styling
| HTML Pattern | React Equivalent |
|---|---|
| Inline `style="color: #22B5AB"` | `className="text-teal-500"` or CSS variable |
| Tailwind CDN classes | Same Tailwind classes (compiled at build time, not CDN) |
| Raw CSS in `<style>` block | Tailwind utilities in JSX, or `globals.css` for truly global styles |
| CSS custom properties | Keep if needed for dynamic values (e.g., accent color) |
| `linear-gradient(135deg, #38bdf8, #6366f1)` | Tailwind `bg-gradient-to-br from-sky-400 to-indigo-500` or define in theme |
### Dependencies
| HTML Pattern | React Equivalent |
|---|---|
| `<script src="https://cdn.tailwindcss.com">` | Tailwind installed via npm, configured in `tailwind.config.ts` |
| `<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/...">` | `npm install jszip` + `import JSZip from 'jszip'` |
| Google Fonts CDN link | `next/font/google` in layout |
### Dialogs
| HTML Pattern | React Equivalent |
|---|---|
| `alert('Message')` | `toast('Message')` via sonner, or `<AlertDialog>` for important messages |
| `confirm('Are you sure?')` | `<AlertDialog>` with confirm/cancel buttons |
| `prompt('Enter name:')` | `<Dialog>` with `<Input>` and submit button |
---
## File Placement Conventions
```
src/
modules/
email-signature/ -- Email signature module
pages/ -- Route-level page components
components/ -- Module-specific UI components
hooks/ -- Module-specific hooks
services/ -- Module-specific services (e.g., zip export)
utils/ -- Module-specific pure functions
data/ -- Static data, presets, defaults
types.ts -- Module type definitions
xml-generator/ -- XML generator module (consolidated)
(same structure)
shared/
components/
ui/ -- shadcn/ui components
utils/ -- Cross-module utilities (formatPhoneNumber, etc.)
hooks/ -- Cross-module hooks
core/
storage/ -- Storage abstraction
i18n/
labels.ts -- All UI text
theme/ -- Theme configuration
config/
companies.ts -- Company profiles
modules.ts -- Module registry (drives sidebar)
```
Module code never imports from another module directly. Shared functionality lives in `src/shared/` or `src/core/`.
---
## Testing Migrated Logic
### Unit Tests
All extracted pure functions and hooks must have unit tests.
**Pure functions** (sanitize, format, generate): straightforward input/output tests.
```ts
// src/modules/xml-generator/utils/__tests__/sanitize-xml-name.test.ts
import { sanitizeXmlName } from '../sanitize-xml-name';
describe('sanitizeXmlName', () => {
it('replaces spaces with underscores', () => {
expect(sanitizeXmlName('Nume Client')).toBe('Nume_Client');
});
it('removes invalid XML characters', () => {
expect(sanitizeXmlName('Preț/m²')).toBe('Prem');
});
it('prepends underscore if starts with digit', () => {
expect(sanitizeXmlName('123Field')).toBe('_123Field');
});
it('returns null for empty input', () => {
expect(sanitizeXmlName('')).toBeNull();
expect(sanitizeXmlName(' ')).toBeNull();
});
});
```
**Hooks**: test with `@testing-library/react-hooks` or `renderHook` from `@testing-library/react`.
```ts
import { renderHook, act } from '@testing-library/react';
import { useCategoryManager } from '../use-category-manager';
describe('useCategoryManager', () => {
it('initializes with default presets', () => {
const { result } = renderHook(() => useCategoryManager(mockStorage));
expect(result.current.categories).toHaveLength(4);
expect(result.current.activeCategory).toBe('Beneficiar');
});
});
```
### Integration Tests
Test full page rendering with mocked storage. Verify that:
- Form inputs update preview in real time.
- Export produces valid HTML/XML.
- Save/load round-trips correctly.
### Regression Testing
Before removing Phase 1 iframe, verify the React version produces identical output to the original HTML tool for a set of reference inputs. Store reference outputs as test fixtures.
For the email signature generator, compare generated HTML string character-by-character for known input configurations.
---
## Legacy Tool Preservation
Original HTML files are kept in the repository under their current paths for reference:
```
emailsignature/emailsignature-config.html
wordXMLgenerator/word-xml-generator-basic.html
wordXMLgenerator/word-xml-generator-medium.html
wordXMLgenerator/word-xml-generator-advanced.html
```
These files are not served by the Next.js application in production. They remain in the repository as:
- Reference implementation for migration accuracy.
- Fallback if a React module has a blocking bug.
- Historical documentation of original tool behavior.
Once Phase 3 is complete and the React versions are stable, legacy files can be moved to a `legacy/` directory or archived in a separate branch. Do not delete them until the React versions have been in production use for at least one release cycle.

View File

@@ -0,0 +1,748 @@
# Module Development Guide
> Step-by-step guide for creating a new ArchiTools module. Read [MODULE-SYSTEM.md](/docs/architecture/MODULE-SYSTEM.md) and [FEATURE-FLAGS.md](/docs/architecture/FEATURE-FLAGS.md) first.
---
## Prerequisites
- Node.js 20+, pnpm
- Familiarity with the module system architecture and feature flag system
- A clear idea of your module's purpose, category, and naming
---
## Walkthrough: Creating a New Module
This guide walks through creating a **"Registru Corespondenta"** (correspondence register) module as a concrete example. Adapt names and types to your use case.
---
### Step 1: Create the Module Directory
```bash
mkdir -p src/modules/registru-corespondenta/{components,hooks,services}
touch src/modules/registru-corespondenta/{types.ts,config.ts,index.ts}
```
Result:
```
src/modules/registru-corespondenta/
components/
hooks/
services/
types.ts
config.ts
index.ts
```
---
### Step 2: Define Types in `types.ts`
Define all TypeScript interfaces and types that are specific to this module. These types are internal to the module and must not be imported by other modules.
```typescript
// src/modules/registru-corespondenta/types.ts
export interface Corespondenta {
id: string;
numar: string; // registration number
data: string; // ISO date string
expeditor: string; // sender
destinatar: string; // recipient
subiect: string; // subject
tip: TipCorespondenta;
status: StatusCorespondenta;
fisiere: string[]; // attached file references
note: string;
creatLa: string; // ISO timestamp
modificatLa: string; // ISO timestamp
}
export type TipCorespondenta = 'intrare' | 'iesire' | 'intern';
export type StatusCorespondenta = 'inregistrat' | 'in-lucru' | 'finalizat' | 'arhivat';
export interface CorrespondentaFilters {
tip?: TipCorespondenta;
status?: StatusCorespondenta;
startDate?: string;
endDate?: string;
searchTerm?: string;
}
```
**Guidelines:**
- Use Romanian names for domain types to match business terminology.
- Keep types self-contained. If you need a shared type (e.g., `DateRange`), check `src/types/` first.
- Export everything -- the barrel export in `index.ts` will control what is actually public.
---
### Step 3: Create `config.ts` with ModuleConfig
This is the module's identity. Every field matters.
```typescript
// src/modules/registru-corespondenta/config.ts
import type { ModuleConfig } from '@/types/modules';
export const registruCorrespondentaConfig: ModuleConfig = {
id: 'registru-corespondenta',
name: 'Registru Corespondenta',
description: 'Evidenta si gestionarea corespondentei primite si expediate.',
icon: 'mail',
route: '/registru-corespondenta',
category: 'operations',
featureFlag: 'module.registru-corespondenta',
visibility: 'all',
version: '1.0.0',
dependencies: [],
storageNamespace: 'architools.registru-corespondenta',
navOrder: 30,
tags: ['corespondenta', 'registru', 'documente'],
};
```
**Checklist for config values:**
- [ ] `id` is kebab-case and unique across all modules
- [ ] `route` starts with `/` and does not collide with existing routes
- [ ] `featureFlag` follows the `module.<id>` convention
- [ ] `storageNamespace` follows the `architools.<id>` convention
- [ ] `navOrder` does not conflict with other modules in the same category (check `src/config/modules.ts`)
---
### Step 4: Implement the Storage Service
Services handle all data persistence. They use the platform's storage abstraction layer and scope all keys under the module's `storageNamespace`.
```typescript
// src/modules/registru-corespondenta/services/corespondenta-storage.ts
import { StorageService } from '@/lib/storage';
import type { Corespondenta, CorrespondentaFilters } from '../types';
import { registruCorrespondentaConfig } from '../config';
const COLLECTION_KEY = 'entries';
const storage = new StorageService(registruCorrespondentaConfig.storageNamespace);
export const corespondentaStorage = {
async getAll(): Promise<Corespondenta[]> {
return storage.get<Corespondenta[]>(COLLECTION_KEY) ?? [];
},
async getById(id: string): Promise<Corespondenta | undefined> {
const all = await this.getAll();
return all.find((item) => item.id === id);
},
async save(item: Corespondenta): Promise<void> {
const all = await this.getAll();
const index = all.findIndex((existing) => existing.id === item.id);
if (index >= 0) {
all[index] = { ...item, modificatLa: new Date().toISOString() };
} else {
all.push({
...item,
creatLa: new Date().toISOString(),
modificatLa: new Date().toISOString(),
});
}
await storage.set(COLLECTION_KEY, all);
},
async remove(id: string): Promise<void> {
const all = await this.getAll();
const filtered = all.filter((item) => item.id !== id);
await storage.set(COLLECTION_KEY, filtered);
},
async filter(filters: CorrespondentaFilters): Promise<Corespondenta[]> {
let results = await this.getAll();
if (filters.tip) {
results = results.filter((item) => item.tip === filters.tip);
}
if (filters.status) {
results = results.filter((item) => item.status === filters.status);
}
if (filters.searchTerm) {
const term = filters.searchTerm.toLowerCase();
results = results.filter(
(item) =>
item.subiect.toLowerCase().includes(term) ||
item.expeditor.toLowerCase().includes(term) ||
item.destinatar.toLowerCase().includes(term)
);
}
if (filters.startDate) {
results = results.filter((item) => item.data >= filters.startDate!);
}
if (filters.endDate) {
results = results.filter((item) => item.data <= filters.endDate!);
}
return results;
},
async getNextNumber(tip: Corespondenta['tip']): Promise<string> {
const all = await this.getAll();
const ofType = all.filter((item) => item.tip === tip);
const year = new Date().getFullYear();
const prefix = tip === 'intrare' ? 'I' : tip === 'iesire' ? 'E' : 'N';
const nextSeq = ofType.length + 1;
return `${prefix}-${year}-${String(nextSeq).padStart(4, '0')}`;
},
};
```
**Rules:**
- Never call `localStorage` or `IndexedDB` directly. Use `StorageService` from `@/lib/storage`.
- Always scope storage through the `storageNamespace` from config.
- Keep services pure data logic -- no React, no UI concerns.
---
### Step 5: Write Hooks for Business Logic
Hooks bridge services and components. They encapsulate state management, loading states, error handling, and business rules.
```typescript
// src/modules/registru-corespondenta/hooks/useCorespondenta.ts
'use client';
import { useState, useEffect, useCallback } from 'react';
import { corespondentaStorage } from '../services/corespondenta-storage';
import type { Corespondenta, CorrespondentaFilters } from '../types';
export function useCorespondenta(filters?: CorrespondentaFilters) {
const [items, setItems] = useState<Corespondenta[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const loadItems = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = filters
? await corespondentaStorage.filter(filters)
: await corespondentaStorage.getAll();
setItems(data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Eroare la incarcarea datelor'));
} finally {
setIsLoading(false);
}
}, [filters]);
useEffect(() => {
loadItems();
}, [loadItems]);
const saveItem = useCallback(async (item: Corespondenta) => {
await corespondentaStorage.save(item);
await loadItems();
}, [loadItems]);
const removeItem = useCallback(async (id: string) => {
await corespondentaStorage.remove(id);
await loadItems();
}, [loadItems]);
return {
items,
isLoading,
error,
saveItem,
removeItem,
refresh: loadItems,
};
}
```
```typescript
// src/modules/registru-corespondenta/hooks/useCorrespondentaForm.ts
'use client';
import { useState, useCallback } from 'react';
import { corespondentaStorage } from '../services/corespondenta-storage';
import type { Corespondenta, TipCorespondenta } from '../types';
import { generateId } from '@/lib/utils';
export function useCorrespondentaForm(tip: TipCorespondenta) {
const [isSaving, setIsSaving] = useState(false);
const createNew = useCallback(async (
data: Omit<Corespondenta, 'id' | 'numar' | 'creatLa' | 'modificatLa'>
): Promise<Corespondenta> => {
setIsSaving(true);
try {
const numar = await corespondentaStorage.getNextNumber(tip);
const item: Corespondenta = {
...data,
id: generateId(),
numar,
creatLa: new Date().toISOString(),
modificatLa: new Date().toISOString(),
};
await corespondentaStorage.save(item);
return item;
} finally {
setIsSaving(false);
}
}, [tip]);
return { createNew, isSaving };
}
```
**Guidelines:**
- Hooks must be in the `hooks/` directory.
- Hooks must not import from other modules.
- Hooks must not contain JSX or rendering logic.
- Keep hooks focused -- one hook per concern (list, form, export, etc.).
---
### Step 6: Build Components
Components live in `components/` and compose hooks + shared UI components.
```typescript
// src/modules/registru-corespondenta/components/CorrespondentaList.tsx
'use client';
import { useCorespondenta } from '../hooks/useCorespondenta';
import type { CorrespondentaFilters } from '../types';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
interface Props {
filters?: CorrespondentaFilters;
onSelect: (id: string) => void;
}
const STATUS_COLORS: Record<string, string> = {
inregistrat: 'bg-blue-100 text-blue-800',
'in-lucru': 'bg-yellow-100 text-yellow-800',
finalizat: 'bg-green-100 text-green-800',
arhivat: 'bg-gray-100 text-gray-800',
};
export function CorrespondentaList({ filters, onSelect }: Props) {
const { items, isLoading, error } = useCorespondenta(filters);
if (isLoading) {
return (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
);
}
if (error) {
return (
<p className="text-sm text-destructive">
Eroare: {error.message}
</p>
);
}
if (items.length === 0) {
return (
<p className="text-sm text-muted-foreground">
Nu exista inregistrari.
</p>
);
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Nr.</TableHead>
<TableHead>Data</TableHead>
<TableHead>Expeditor</TableHead>
<TableHead>Subiect</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow
key={item.id}
className="cursor-pointer"
onClick={() => onSelect(item.id)}
>
<TableCell className="font-mono">{item.numar}</TableCell>
<TableCell>{new Date(item.data).toLocaleDateString('ro-RO')}</TableCell>
<TableCell>{item.expeditor}</TableCell>
<TableCell>{item.subiect}</TableCell>
<TableCell>
<Badge className={STATUS_COLORS[item.status]}>
{item.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
```
The root component ties everything together and serves as the module's entry point:
```typescript
// src/modules/registru-corespondenta/components/RegistruCorrespondentaModule.tsx
'use client';
import { useState } from 'react';
import { CorrespondentaList } from './CorrespondentaList';
import type { CorrespondentaFilters } from '../types';
export default function RegistruCorrespondentaModule() {
const [filters, setFilters] = useState<CorrespondentaFilters>({});
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Registru Corespondenta</h1>
{/* Add "Adauga" button here */}
</div>
{/* Filter bar component here */}
<CorrespondentaList filters={filters} onSelect={setSelectedId} />
{/* Detail/edit panel here */}
</div>
);
}
```
**Rules:**
- The root component must be a `default` export (required by `React.lazy`).
- Use shared UI components from `@/components/ui/` (shadcn/ui).
- Do not import components from other modules.
- Mark all client-side components with `'use client'`.
---
### Step 7: Create the Barrel Export in `index.ts`
The barrel export defines the module's public API. The rest of the system may only import from this file.
```typescript
// src/modules/registru-corespondenta/index.ts
export { registruCorrespondentaConfig as config } from './config';
export { default } from './components/RegistruCorrespondentaModule';
```
**Important:** The default export must be the root component. This is what `React.lazy(() => import('@/modules/registru-corespondenta'))` will resolve to.
---
### Step 8: Register the Module in `src/config/modules.ts`
```typescript
// src/config/modules.ts
import { registruCorrespondentaConfig } from '@/modules/registru-corespondenta/config';
// ... other imports
const moduleConfigs: ModuleConfig[] = [
// ... existing modules
registruCorrespondentaConfig,
];
```
Then add the lazy component entry in the module loader:
```typescript
// src/lib/module-loader.ts
const lazyComponents: Record<string, React.LazyExoticComponent<React.ComponentType>> = {
// ... existing entries
'registru-corespondenta': React.lazy(() => import('@/modules/registru-corespondenta')),
};
```
---
### Step 9: Add the Feature Flag in `src/config/flags.ts`
```typescript
// src/config/flags.ts -- add to the defaultFlags array
{
key: 'module.registru-corespondenta',
enabled: true,
label: 'Registru Corespondenta',
description: 'Activeaza modulul de evidenta a corespondentei.',
category: 'module',
overridable: true,
},
```
The `key` must exactly match the `featureFlag` value in your module's `config.ts`.
---
### Step 10: Add the Route
Create the Next.js App Router page for your module.
```typescript
// src/app/(modules)/registru-corespondenta/page.tsx
'use client';
import { ModuleLoader } from '@/lib/module-loader';
import { moduleRegistry } from '@/config/modules';
import { useFeatureFlag } from '@/hooks/useFeatureFlag';
import { notFound } from 'next/navigation';
export default function RegistruCorrespondentaPage() {
const config = moduleRegistry.get('registru-corespondenta');
if (!config) {
notFound();
}
const enabled = useFeatureFlag(config.featureFlag);
if (!enabled) {
notFound();
}
return <ModuleLoader config={config} />;
}
```
The route path must match the `route` field in your module config (minus the leading `/`).
---
### Step 11: Test with Flag On/Off
**Flag enabled (default):**
1. Start the dev server: `pnpm dev`
2. Verify the module appears in the sidebar under its category.
3. Click the sidebar link. Verify the module loads and displays correctly.
4. Open the browser Network tab. Confirm a separate chunk is fetched for the module.
5. Test all CRUD operations. Verify localStorage keys are prefixed with `architools.registru-corespondenta`.
**Flag disabled:**
1. In `src/config/flags.ts`, set `enabled: false` for `module.registru-corespondenta` (or use the env var `NEXT_PUBLIC_FLAG_MODULE_REGISTRU_CORESPONDENTA=false`).
2. Verify the module disappears from the sidebar.
3. Navigate directly to `/registru-corespondenta`. Verify you get a 404.
4. Open the Network tab. Confirm no chunk for the module is fetched anywhere in the application.
**Error boundary:**
1. Temporarily add `throw new Error('test')` to the root component's render.
2. Verify the error boundary catches it, displays the error UI, and the rest of the app remains functional.
3. Click "Reincearca" and verify the module attempts to re-render.
4. Remove the test error.
---
## Module Checklist
Use this checklist before submitting a PR for a new module.
### Structure
- [ ] Module directory is at `src/modules/<module-id>/`
- [ ] Directory contains: `components/`, `hooks/`, `services/`, `types.ts`, `config.ts`, `index.ts`
- [ ] No files outside the module directory reference internal module files (only `index.ts` is imported externally)
### Configuration
- [ ] `config.ts` exports a valid `ModuleConfig`
- [ ] `id` is kebab-case and unique
- [ ] `route` starts with `/`, is unique, and matches the App Router path
- [ ] `featureFlag` matches the key in `flags.ts`
- [ ] `storageNamespace` follows `architools.<id>` convention and is unique
- [ ] `navOrder` does not conflict within its category
### Registration
- [ ] Config imported and added to `moduleConfigs` in `src/config/modules.ts`
- [ ] Lazy component added to `lazyComponents` in `src/lib/module-loader.ts`
- [ ] Feature flag added to `defaultFlags` in `src/config/flags.ts`
- [ ] Route page created at `src/app/(modules)/<route>/page.tsx`
### Code Quality
- [ ] All types defined in `types.ts`
- [ ] Storage access goes through `StorageService`, scoped to `storageNamespace`
- [ ] No direct `localStorage`/`IndexedDB` calls
- [ ] No imports from other modules (`@/modules/*` other than own)
- [ ] Root component is a `default` export
- [ ] All client components have `'use client'` directive
- [ ] Hooks contain no JSX
### Testing
- [ ] Module loads correctly with flag enabled
- [ ] Module is completely absent (no chunk, no nav item) with flag disabled
- [ ] Direct URL navigation to disabled module returns 404
- [ ] Error boundary catches thrown errors gracefully
- [ ] Storage keys are correctly namespaced (inspect in DevTools > Application > Local Storage)
- [ ] Module works after page refresh (data persists)
---
## Anti-Patterns
### 1. Importing from another module
```typescript
// WRONG
import { formatDeviz } from '@/modules/devize-generator/services/formatter';
// RIGHT: If you need shared logic, move it to src/lib/ or src/utils/
import { formatCurrency } from '@/lib/formatters';
```
Modules are isolated units. Cross-module imports create hidden coupling and break the ability to independently enable/disable modules.
### 2. Bypassing the storage abstraction
```typescript
// WRONG
localStorage.setItem('registru-corespondenta.entries', JSON.stringify(data));
// RIGHT
const storage = new StorageService(config.storageNamespace);
await storage.set('entries', data);
```
Direct storage calls bypass namespace isolation, offline support, and future migration to IndexedDB or a remote backend.
### 3. Skipping the feature flag gate on the route
```typescript
// WRONG: Module is always accessible via URL even when flag is disabled
export default function MyModulePage() {
return <ModuleLoader config={config} />;
}
// RIGHT
export default function MyModulePage() {
const enabled = useFeatureFlag(config.featureFlag);
if (!enabled) notFound();
return <ModuleLoader config={config} />;
}
```
Without the flag check on the route, a user can access a disabled module by typing the URL directly.
### 4. Putting business logic in components
```typescript
// WRONG: Component directly manages storage and transforms data
function MyList() {
const [items, setItems] = useState([]);
useEffect(() => {
const raw = localStorage.getItem('...');
const parsed = JSON.parse(raw);
const filtered = parsed.filter(/* complex logic */);
setItems(filtered);
}, []);
}
// RIGHT: Component delegates to a hook, which delegates to a service
function MyList() {
const { items, isLoading } = useCorespondenta(filters);
}
```
Business logic in components makes code untestable, unreusable, and hard to maintain. Follow the layers: Component -> Hook -> Service -> Storage.
### 5. Non-unique identifiers
```typescript
// WRONG: Reusing an ID or namespace that another module already uses
export const myConfig: ModuleConfig = {
id: 'oferte', // already taken by the Oferte module
storageNamespace: 'architools.oferte', // already taken
};
```
This will cause data corruption and routing conflicts. The registry validation catches duplicate IDs, but namespace collisions are only caught by code review.
### 6. Side effects in config.ts
```typescript
// WRONG: config.ts should be pure data, no side effects
console.log('Module loaded!');
await initializeDatabase();
export const config: ModuleConfig = { ... };
// RIGHT: config.ts exports only a plain object
export const config: ModuleConfig = { ... };
```
Config files are imported at registration time for all modules, regardless of whether they are enabled. Side effects in config files run unconditionally and defeat the purpose of lazy loading.
### 7. Exporting internal types from index.ts
```typescript
// WRONG: Exposing internal implementation types to the outside
export { config } from './config';
export { default } from './components/MyModule';
export type { InternalState, PrivateHelper } from './types'; // leaking internals
// RIGHT: Only export what the platform needs
export { config } from './config';
export { default } from './components/MyModule';
```
The barrel export should be minimal. The only consumers are the module registry (needs `config`) and the module loader (needs the default component). Internal types stay internal.
### 8. Monolithic root component
```typescript
// WRONG: One 500-line component that does everything
export default function MyModule() {
// form logic, list logic, filter logic, modal logic, all inline
}
// RIGHT: Compose from focused sub-components
export default function MyModule() {
return (
<>
<MyModuleHeader />
<MyModuleFilters />
<MyModuleList />
<MyModuleDetailPanel />
</>
);
}
```
Break the module into focused components. The root component should primarily compose child components and manage top-level layout.

View File

@@ -0,0 +1,864 @@
# Testing Strategy
> ArchiTools testing guide -- tools, conventions, coverage targets, and anti-patterns.
---
## Overview
ArchiTools follows a testing pyramid: many fast unit tests at the base, fewer integration tests in the middle, and a small number of E2E tests at the top. Tests are co-located with the source code they test, not in a separate directory tree.
```
┌─────────┐
│ E2E │ Playwright -- critical user flows
│ (few) │
┌┴─────────┴┐
│Integration │ Vitest -- module services with real storage
│ (moderate) │
┌┴────────────┴┐
│ Component │ Vitest + React Testing Library
│ (moderate) │
┌┴──────────────┴┐
│ Unit │ Vitest -- hooks, services, utilities
│ (many) │
└────────────────┘
```
---
## Tools
| Layer | Tool | Why |
|---|---|---|
| Unit | Vitest | Fast, native ESM, TypeScript-first, compatible with Next.js |
| Component | Vitest + React Testing Library | Tests component behavior from the user's perspective |
| Integration | Vitest | Same runner, but tests span module service + storage layers |
| E2E | Playwright | Cross-browser, reliable, good Next.js support |
### Vitest Configuration
```typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/**/*.test.{ts,tsx}',
'src/**/*.d.ts',
'src/test/**',
'src/types/**',
],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
```
### Test Setup
```typescript
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
// Mock localStorage for all tests
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value; },
removeItem: (key: string) => { delete store[key]; },
clear: () => { store = {}; },
get length() { return Object.keys(store).length; },
key: (index: number) => Object.keys(store)[index] ?? null,
};
})();
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
// Reset storage between tests
beforeEach(() => {
localStorage.clear();
});
```
---
## File Organization
Tests are co-located with the source files they test. No separate `__tests__` directories.
```
src/modules/registratura/
components/
RegistryTable.tsx
RegistryTable.test.tsx # component test
RegistryForm.tsx
RegistryForm.test.tsx # component test
hooks/
use-registry.ts
use-registry.test.ts # hook unit test
services/
registry-service.ts
registry-service.test.ts # service unit test
registry-service.integration.test.ts # integration test
types.ts
config.ts
src/lib/tags/
tag-service.ts
tag-service.test.ts # unit test
tag-service.integration.test.ts # integration test
src/lib/storage/
storage-service.ts
storage-service.test.ts # unit test
src/hooks/
useFeatureFlag.ts
useFeatureFlag.test.ts # unit test
```
### Naming Convention
| File type | Pattern | Example |
|---|---|---|
| Unit test | `[name].test.ts` | `registry-service.test.ts` |
| Component test | `[Component].test.tsx` | `RegistryTable.test.tsx` |
| Integration test | `[name].integration.test.ts` | `registry-service.integration.test.ts` |
| E2E test | `[flow].spec.ts` (in `e2e/`) | `e2e/registratura.spec.ts` |
| Test utility | `[name].ts` (in `src/test/`) | `src/test/factories.ts` |
---
## Unit Tests
### What to Test
- **Services:** All CRUD operations, validation logic, error handling, storage key construction.
- **Hooks:** State transitions, side effects, return value shape, error states.
- **Utility functions:** Transformations, formatters, parsers, validators.
- **Storage adapters:** Read/write/delete operations, namespace isolation.
- **Feature flag evaluation:** Flag resolution logic, default values.
### What NOT to Test
- TypeScript types (they are compile-time only).
- Static configuration objects (they have no logic).
- Third-party library internals (test your usage, not their code).
### Service Test Example
```typescript
// src/modules/registratura/services/registry-service.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { RegistryService } from './registry-service';
import { createMockStorageService } from '@/test/mocks/storage';
describe('RegistryService', () => {
let service: RegistryService;
let storage: ReturnType<typeof createMockStorageService>;
beforeEach(() => {
storage = createMockStorageService();
service = new RegistryService(storage);
});
describe('createEntry', () => {
it('assigns an ID and timestamps on creation', async () => {
const entry = await service.createEntry({
title: 'Cerere CU',
type: 'incoming',
tagIds: [],
});
expect(entry.id).toBeDefined();
expect(entry.createdAt).toBeDefined();
expect(entry.updatedAt).toBeDefined();
});
it('rejects entries with empty title', async () => {
await expect(
service.createEntry({ title: '', type: 'incoming', tagIds: [] })
).rejects.toThrow('Title is required');
});
it('stores the entry under the correct namespace', async () => {
await service.createEntry({
title: 'Cerere CU',
type: 'incoming',
tagIds: [],
});
expect(storage.setItem).toHaveBeenCalledWith(
expect.stringContaining('architools.registratura'),
expect.any(String)
);
});
});
describe('getEntries', () => {
it('returns an empty array when no entries exist', async () => {
const entries = await service.getEntries();
expect(entries).toEqual([]);
});
it('returns entries sorted by creation date descending', async () => {
await service.createEntry({ title: 'First', type: 'incoming', tagIds: [] });
await service.createEntry({ title: 'Second', type: 'incoming', tagIds: [] });
const entries = await service.getEntries();
expect(entries[0].title).toBe('Second');
expect(entries[1].title).toBe('First');
});
});
});
```
### Hook Test Example
```typescript
// src/modules/registratura/hooks/use-registry.test.ts
import { describe, it, expect } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useRegistry } from './use-registry';
import { TestProviders } from '@/test/providers';
describe('useRegistry', () => {
it('loads entries on mount', async () => {
const { result } = renderHook(() => useRegistry(), {
wrapper: TestProviders,
});
expect(result.current.isLoading).toBe(true);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.entries).toEqual([]);
});
it('adds an entry and updates the list', async () => {
const { result } = renderHook(() => useRegistry(), {
wrapper: TestProviders,
});
await waitFor(() => expect(result.current.isLoading).toBe(false));
await act(async () => {
await result.current.addEntry({
title: 'Test Entry',
type: 'incoming',
tagIds: [],
});
});
expect(result.current.entries).toHaveLength(1);
expect(result.current.entries[0].title).toBe('Test Entry');
});
});
```
---
## Component Tests
### Principles
- Test **behavior**, not implementation. Click buttons, fill inputs, assert on visible output.
- Use `screen.getByRole`, `screen.getByText`, `screen.getByLabelText` -- prefer accessible queries.
- Do not test CSS classes or DOM structure. Test what the user sees and can interact with.
- Mock services at the hook level, not at the component level. Components should receive data through hooks.
### Component Test Example
```typescript
// src/modules/registratura/components/RegistryTable.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { RegistryTable } from './RegistryTable';
import { createTestEntry } from '@/test/factories';
describe('RegistryTable', () => {
const mockEntries = [
createTestEntry({ title: 'Cerere CU - Casa Popescu', type: 'incoming' }),
createTestEntry({ title: 'Autorizatie construire', type: 'outgoing' }),
];
it('renders all entries', () => {
render(<RegistryTable entries={mockEntries} onEdit={vi.fn()} onDelete={vi.fn()} />);
expect(screen.getByText('Cerere CU - Casa Popescu')).toBeInTheDocument();
expect(screen.getByText('Autorizatie construire')).toBeInTheDocument();
});
it('calls onEdit when edit button is clicked', async () => {
const onEdit = vi.fn();
render(<RegistryTable entries={mockEntries} onEdit={onEdit} onDelete={vi.fn()} />);
const editButtons = screen.getAllByRole('button', { name: /editeaza/i });
await userEvent.click(editButtons[0]);
expect(onEdit).toHaveBeenCalledWith(mockEntries[0].id);
});
it('shows empty state when no entries exist', () => {
render(<RegistryTable entries={[]} onEdit={vi.fn()} onDelete={vi.fn()} />);
expect(screen.getByText(/nu exista inregistrari/i)).toBeInTheDocument();
});
it('filters entries when tag filter is applied', async () => {
render(
<RegistryTable
entries={mockEntries}
onEdit={vi.fn()}
onDelete={vi.fn()}
activeTagIds={[mockEntries[0].tagIds[0]]}
/>
);
expect(screen.getByText('Cerere CU - Casa Popescu')).toBeInTheDocument();
expect(screen.queryByText('Autorizatie construire')).not.toBeInTheDocument();
});
});
```
---
## Integration Tests
Integration tests verify that module services work correctly with the real storage adapter (localStorage mock from the test setup, not a hand-written mock).
### Module-Level Integration
```typescript
// src/modules/registratura/services/registry-service.integration.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { RegistryService } from './registry-service';
import { LocalStorageAdapter } from '@/lib/storage/local-storage-adapter';
describe('RegistryService (integration)', () => {
let service: RegistryService;
beforeEach(() => {
localStorage.clear();
const storage = new LocalStorageAdapter('architools.registratura');
service = new RegistryService(storage);
});
it('persists and retrieves entries across service instances', async () => {
await service.createEntry({
title: 'Persistent Entry',
type: 'incoming',
tagIds: [],
});
// Create a new service instance pointing to the same storage
const storage2 = new LocalStorageAdapter('architools.registratura');
const service2 = new RegistryService(storage2);
const entries = await service2.getEntries();
expect(entries).toHaveLength(1);
expect(entries[0].title).toBe('Persistent Entry');
});
it('does not leak data across storage namespaces', async () => {
await service.createEntry({
title: 'Registry Entry',
type: 'incoming',
tagIds: [],
});
const otherStorage = new LocalStorageAdapter('architools.other-module');
const keys = Object.keys(localStorage).filter((k) =>
k.startsWith('architools.other-module')
);
expect(keys).toHaveLength(0);
});
});
```
### Cross-Module Integration
Test that the tagging system works correctly when used by a module.
```typescript
// src/test/integration/tags-cross-module.integration.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { TagService } from '@/lib/tags/tag-service';
import { RegistryService } from '@/modules/registratura/services/registry-service';
import { LocalStorageAdapter } from '@/lib/storage/local-storage-adapter';
describe('Tags cross-module integration', () => {
let tagService: TagService;
let registryService: RegistryService;
beforeEach(() => {
localStorage.clear();
tagService = new TagService(new LocalStorageAdapter('architools.tags'));
registryService = new RegistryService(
new LocalStorageAdapter('architools.registratura')
);
});
it('registry entries can reference tags from the tag service', async () => {
const tag = await tagService.createTag({
label: 'DTAC',
category: 'phase',
scope: 'global',
color: '#14b8a6',
});
const entry = await registryService.createEntry({
title: 'Documentatie DTAC',
type: 'incoming',
tagIds: [tag.id],
});
const resolvedTags = await tagService.getAllTags();
const entryTags = resolvedTags.filter((t) => entry.tagIds.includes(t.id));
expect(entryTags).toHaveLength(1);
expect(entryTags[0].label).toBe('DTAC');
});
it('handles deleted tags gracefully in entity tag lists', async () => {
const tag = await tagService.createTag({
label: 'Temporary',
category: 'custom',
scope: 'global',
});
await registryService.createEntry({
title: 'Entry with temp tag',
type: 'incoming',
tagIds: [tag.id],
});
await tagService.deleteTag(tag.id);
const allTags = await tagService.getAllTags();
const entries = await registryService.getEntries();
// The entry still references the deleted tag ID
expect(entries[0].tagIds).toContain(tag.id);
// But the tag no longer exists
expect(allTags.find((t) => t.id === tag.id)).toBeUndefined();
});
});
```
---
## E2E Tests
### Setup
```typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? 'github' : 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
```
### Critical Paths
E2E tests cover the flows that, if broken, would block daily use of ArchiTools.
| Flow | File | What it tests |
|---|---|---|
| Navigation | `e2e/navigation.spec.ts` | Sidebar renders enabled modules, disabled modules are absent, clicking navigates |
| Registratura CRUD | `e2e/registratura.spec.ts` | Create, read, update, delete registry entries |
| Tag management | `e2e/tag-manager.spec.ts` | Create tag, apply to entity, filter by tag |
| Feature flags | `e2e/feature-flags.spec.ts` | Toggle flag off, verify module disappears from sidebar and route returns 404 |
| Email Signature | `e2e/email-signature.spec.ts` | Generate signature, preview renders, copy to clipboard |
| Data export/import | `e2e/data-export.spec.ts` | Export data as JSON, clear storage, import, verify data restored |
### E2E Test Example
```typescript
// e2e/registratura.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Registratura', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/registratura');
});
test('creates a new registry entry', async ({ page }) => {
await page.getByRole('button', { name: /adauga/i }).click();
await page.getByLabel(/titlu/i).fill('Cerere CU - Casa Test');
await page.getByLabel(/tip/i).selectOption('incoming');
await page.getByRole('button', { name: /salveaza/i }).click();
await expect(page.getByText('Cerere CU - Casa Test')).toBeVisible();
});
test('edits an existing entry', async ({ page }) => {
// Create an entry first
await page.getByRole('button', { name: /adauga/i }).click();
await page.getByLabel(/titlu/i).fill('Original Title');
await page.getByLabel(/tip/i).selectOption('incoming');
await page.getByRole('button', { name: /salveaza/i }).click();
// Edit it
await page.getByRole('button', { name: /editeaza/i }).first().click();
await page.getByLabel(/titlu/i).clear();
await page.getByLabel(/titlu/i).fill('Updated Title');
await page.getByRole('button', { name: /salveaza/i }).click();
await expect(page.getByText('Updated Title')).toBeVisible();
await expect(page.getByText('Original Title')).not.toBeVisible();
});
test('deletes an entry', async ({ page }) => {
// Create an entry first
await page.getByRole('button', { name: /adauga/i }).click();
await page.getByLabel(/titlu/i).fill('To Be Deleted');
await page.getByLabel(/tip/i).selectOption('incoming');
await page.getByRole('button', { name: /salveaza/i }).click();
// Delete it
await page.getByRole('button', { name: /sterge/i }).first().click();
await page.getByRole('button', { name: /confirma/i }).click();
await expect(page.getByText('To Be Deleted')).not.toBeVisible();
});
});
```
### Feature Flag E2E Test
```typescript
// e2e/feature-flags.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Feature Flags', () => {
test('disabled module is absent from sidebar', async ({ page }) => {
// Set feature flag to disabled via localStorage before navigation
await page.goto('/');
await page.evaluate(() => {
localStorage.setItem(
'architools.flags',
JSON.stringify({ 'module.registratura': false })
);
});
await page.reload();
const sidebar = page.getByRole('navigation');
await expect(sidebar.getByText('Registratura')).not.toBeVisible();
});
test('disabled module route returns 404 or redirects', async ({ page }) => {
await page.goto('/');
await page.evaluate(() => {
localStorage.setItem(
'architools.flags',
JSON.stringify({ 'module.registratura': false })
);
});
await page.goto('/registratura');
// Should show a "module not found" or redirect to dashboard
await expect(page.getByText(/nu a fost gasit/i)).toBeVisible();
});
});
```
---
## Test Utilities
### Mock Storage Service
```typescript
// src/test/mocks/storage.ts
import { vi } from 'vitest';
export function createMockStorageService() {
const store = new Map<string, string>();
return {
getItem: vi.fn((key: string) => store.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => { store.set(key, value); }),
removeItem: vi.fn((key: string) => { store.delete(key); }),
clear: vi.fn(() => store.clear()),
getAllKeys: vi.fn(() => Array.from(store.keys())),
};
}
```
### Mock Feature Flag Provider
```typescript
// src/test/mocks/feature-flags.ts
import React from 'react';
import { FeatureFlagContext } from '@/lib/feature-flags/context';
interface MockFlagProviderProps {
flags?: Record<string, boolean>;
children: React.ReactNode;
}
export function MockFeatureFlagProvider({
flags = {},
children,
}: MockFlagProviderProps) {
const allEnabled = new Proxy(flags, {
get: (target, prop: string) => target[prop] ?? true,
});
return (
<FeatureFlagContext.Provider value={allEnabled}>
{children}
</FeatureFlagContext.Provider>
);
}
```
### Test Data Factories
```typescript
// src/test/factories.ts
import { v4 as uuid } from 'uuid';
import type { Tag } from '@/types/tags';
export function createTestEntry(overrides: Partial<RegistryEntry> = {}): RegistryEntry {
return {
id: uuid(),
title: 'Test Entry',
type: 'incoming',
tagIds: [],
visibility: 'all',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
export function createTestTag(overrides: Partial<Tag> = {}): Tag {
return {
id: uuid(),
label: 'Test Tag',
category: 'custom',
scope: 'global',
color: '#6366f1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
export function createTestContact(overrides: Partial<Contact> = {}): Contact {
return {
id: uuid(),
name: 'Ion Popescu',
email: 'ion@example.com',
phone: '+40 712 345 678',
tagIds: [],
visibility: 'all',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
```
### Test Providers Wrapper
```typescript
// src/test/providers.tsx
import React from 'react';
import { MockFeatureFlagProvider } from './mocks/feature-flags';
interface TestProvidersProps {
children: React.ReactNode;
flags?: Record<string, boolean>;
}
export function TestProviders({ children, flags }: TestProvidersProps) {
return (
<MockFeatureFlagProvider flags={flags}>
{children}
</MockFeatureFlagProvider>
);
}
```
---
## Coverage Targets
| Layer | Target | Rationale |
|---|---|---|
| Services | 90%+ | Services contain all business logic and data access. Bugs here corrupt data. |
| Hooks | 80%+ | Hooks orchestrate service calls and manage state. Most logic is delegated to services. |
| Components | 70%+ | Component tests cover interactive behavior. Layout and styling are not tested. |
| Utilities | 95%+ | Pure functions with clear inputs/outputs. Easy to test exhaustively. |
| Overall | 75%+ | Weighted average across all layers. |
Coverage is measured with V8 via Vitest and reported in CI. Coverage gates are advisory, not blocking, during the initial build-out phase. They become blocking once the codebase stabilizes.
---
## CI Integration
Tests run on every pull request. The pipeline:
```yaml
# .github/workflows/test.yml (or equivalent CI config)
steps:
- name: Install dependencies
run: npm ci
- name: Type check
run: npx tsc --noEmit
- name: Lint
run: npm run lint
- name: Unit + Component + Integration tests
run: npx vitest run --coverage
- name: E2E tests
run: npx playwright test
- name: Upload coverage
run: # upload lcov report to coverage service
```
**Rules:**
- All tests must pass before a PR can be merged.
- Coverage regressions (dropping below target) produce a warning, not a blocking failure.
- E2E tests run against a production build (`npm run build && npm start`) in CI, not against the dev server.
- Flaky tests are quarantined (moved to a `*.flaky.test.ts` suffix) and tracked for repair. They do not block the pipeline.
---
## Testing Anti-Patterns
These patterns are explicitly avoided in ArchiTools tests.
### 1. Testing Implementation Details
**Bad:** Asserting on internal state, private methods, or specific DOM structure.
```typescript
// BAD: testing internal state
expect(component.state.isOpen).toBe(true);
// BAD: testing CSS classes
expect(container.querySelector('.modal-active')).toBeTruthy();
```
**Good:** Assert on what the user sees or what the API returns.
```typescript
// GOOD: testing visible behavior
expect(screen.getByRole('dialog')).toBeVisible();
```
### 2. Snapshot Testing for Components
Snapshot tests are brittle and provide low signal. A single class name change breaks the snapshot, and reviewers rubber-stamp snapshot updates. Do not use `toMatchSnapshot()` or `toMatchInlineSnapshot()` for component output.
### 3. Mocking Everything
Over-mocking eliminates the value of the test. If a service test mocks the storage adapter, the formatter, and the validator, it is testing nothing but the function's wiring.
**Rule:** Mock at the boundary. For service tests, mock storage. For hook tests, mock the service. For component tests, mock the hook. One layer deep, no more.
### 4. Testing the Framework
Do not test that React renders a component, that `useState` works, or that `useEffect` fires. Test your logic, not React's.
```typescript
// BAD: testing that React works
it('renders without crashing', () => {
render(<MyComponent />);
});
```
This test passes for an empty `<div>` and catches nothing useful. Test specific behavior instead.
### 5. Coupling Tests to Data Order
Tests that depend on array order without explicitly sorting are fragile. If the service returns entries sorted by date and the test asserts on `entries[0].title`, it will break when a second entry has the same timestamp.
**Rule:** Either sort explicitly in the test or use `expect.arrayContaining` / `toContainEqual`.
### 6. Not Cleaning Up State
Tests that write to localStorage (or any shared state) without clearing it in `beforeEach` will produce order-dependent failures. The test setup file clears localStorage globally, but custom state (e.g., module-level caches) must be reset in the test's own `beforeEach`.
### 7. Giant E2E Tests
A single E2E test that creates 10 entities, edits 5, deletes 3, and checks a report is slow, fragile, and hard to debug. Keep E2E tests focused on one flow. Use `test.beforeEach` to set up preconditions via API/localStorage rather than through the UI.
### 8. Ignoring Async Behavior
```typescript
// BAD: not waiting for async updates
const { result } = renderHook(() => useRegistry());
expect(result.current.entries).toHaveLength(1); // may be empty -- hook hasn't loaded yet
// GOOD: wait for the async operation
await waitFor(() => {
expect(result.current.entries).toHaveLength(1);
});
```

View File

@@ -0,0 +1,586 @@
# UI Design System
ArchiTools internal design system reference. All UI decisions flow from this document.
---
## Design Philosophy
ArchiTools serves architecture and engineering professionals at Beletage SRL, Urban Switch SRL, and Studii de Teren SRL. The interface must reflect the discipline of the work itself: precise, structured, technically grounded.
**Guiding principles:**
- **Professional over playful.** No rounded bubbly elements, no bright consumer gradients, no emoji-heavy interfaces. The aesthetic is closer to a CAD toolbar than a social media dashboard.
- **Information-dense but not cluttered.** Architecture professionals need data visible at a glance. Favor card-based layouts with clear hierarchy over sparse minimalist voids.
- **Technical confidence.** Use monospaced fonts for data fields, structured grids for layout, and precise spacing. The UI should feel like a well-organized technical drawing.
- **Consistent across tools.** Every module (email signatures, XML generators, future tools) must feel like part of the same platform, not a collection of standalone pages.
- **Dark and light with equal quality.** Both themes are first-class. The dark theme is the default (matching existing tool aesthetics). The light theme must be equally polished.
---
## Color System
### Brand Colors
| Token | Hex | Usage |
|---|---|---|
| `brand-teal` | `#22B5AB` | Primary accent. Beletage brand teal. Used for active states, primary buttons, links, focus rings. |
| `brand-teal-light` | `#2DD4BF` | Hover state for teal elements. |
| `brand-teal-dark` | `#14978F` | Pressed/active state. |
Each company in the group may define an override accent color. The teal serves as the platform default and Beletage-specific accent.
| Company | Accent | Usage Context |
|---|---|---|
| Beletage SRL | `#22B5AB` (teal) | Architecture projects |
| Urban Switch SRL | TBD | Urban planning projects |
| Studii de Teren SRL | TBD | Land survey projects |
The company selector in the header drives the active accent color via a CSS custom property (`--accent`).
### Slate Backgrounds
Derived from Tailwind's `slate` scale. These form the structural palette for both themes.
**Dark theme (default):**
| Token | Tailwind Class | Hex | Usage |
|---|---|---|---|
| `bg-app` | `bg-slate-950` | `#020617` | Application background |
| `bg-card` | `bg-slate-900` | `#0f172a` | Card surfaces |
| `bg-card-elevated` | `bg-slate-800` | `#1e293b` | Elevated cards, dropdowns, popovers |
| `bg-input` | `bg-slate-950` | `#020617` | Input field backgrounds |
| `border-default` | `border-slate-700` | `#334155` | Card borders, dividers |
| `border-subtle` | `border-slate-800` | `#1e293b` | Subtle separators |
| `text-primary` | `text-slate-100` | `#f1f5f9` | Primary text |
| `text-secondary` | `text-slate-400` | `#94a3b8` | Secondary text, labels |
| `text-muted` | `text-slate-500` | `#64748b` | Disabled text, placeholders |
**Light theme:**
| Token | Tailwind Class | Hex | Usage |
|---|---|---|---|
| `bg-app` | `bg-slate-50` | `#f8fafc` | Application background |
| `bg-card` | `bg-white` | `#ffffff` | Card surfaces |
| `bg-card-elevated` | `bg-slate-50` | `#f8fafc` | Elevated cards |
| `bg-input` | `bg-white` | `#ffffff` | Input field backgrounds |
| `border-default` | `border-slate-200` | `#e2e8f0` | Card borders, dividers |
| `border-subtle` | `border-slate-100` | `#f1f5f9` | Subtle separators |
| `text-primary` | `text-slate-900` | `#0f172a` | Primary text |
| `text-secondary` | `text-slate-600` | `#475569` | Secondary text, labels |
| `text-muted` | `text-slate-400` | `#94a3b8` | Disabled text, placeholders |
### Semantic Colors
| Semantic | Light | Dark | Usage |
|---|---|---|---|
| `success` | `#16a34a` (green-600) | `#22c55e` (green-500) | Confirmations, valid states |
| `warning` | `#d97706` (amber-600) | `#f59e0b` (amber-500) | Caution states, non-blocking issues |
| `error` | `#dc2626` (red-600) | `#ef4444` (red-500) | Errors, destructive actions |
| `info` | `#2563eb` (blue-600) | `#3b82f6` (blue-500) | Informational highlights |
Each semantic color has a background variant at 10% opacity for alert/badge backgrounds:
- `bg-success`: `success / 10%` over card background
- Same pattern for `warning`, `error`, `info`
### Theme Implementation
Theme is managed by `next-themes` with the `attribute="class"` strategy. Tailwind's `darkMode: "class"` is enabled. All theme-dependent styles use the `dark:` prefix.
```tsx
// layout.tsx
import { ThemeProvider } from 'next-themes';
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
{children}
</ThemeProvider>
```
CSS custom properties for the accent color are set on `<html>` based on the active company selection:
```css
:root {
--accent: 34 181 171; /* #22B5AB in RGB */
--accent-foreground: 255 255 255;
}
```
Components reference accent via `bg-[rgb(var(--accent))]` or through shadcn/ui's HSL-based theming variables in `globals.css`.
---
## Typography
### Font Stack
Primary: `Inter` (loaded via `next/font/google`), falling back to `system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`.
Monospace (for code, XML output, data fields): `"JetBrains Mono", "Fira Code", ui-monospace, monospace`.
### Size Scale
All sizes reference Tailwind's default scale. Do not use arbitrary pixel values.
| Token | Tailwind | Size | Usage |
|---|---|---|---|
| `text-xs` | `text-xs` | 0.75rem / 12px | Badges, fine print, metadata |
| `text-sm` | `text-sm` | 0.875rem / 14px | Labels, secondary text, table cells |
| `text-base` | `text-base` | 1rem / 16px | Body text, input values |
| `text-lg` | `text-lg` | 1.125rem / 18px | Card titles, section headers |
| `text-xl` | `text-xl` | 1.25rem / 20px | Page section titles |
| `text-2xl` | `text-2xl` | 1.5rem / 24px | Page titles |
| `text-3xl` | `text-3xl` | 1.875rem / 30px | Dashboard hero stats only |
### Font Weight
| Weight | Tailwind | Usage |
|---|---|---|
| 400 | `font-normal` | Body text |
| 500 | `font-medium` | Labels, table headers, navigation items |
| 600 | `font-semibold` | Card titles, section headers |
| 700 | `font-bold` | Page titles, stat values |
### Line Height
Use Tailwind defaults. Override only for tight stat displays (`leading-tight` / `leading-none` on large numeric values).
---
## Component Library
### Base: shadcn/ui
All interactive components are built on [shadcn/ui](https://ui.shadcn.com/). Components are installed into `src/shared/components/ui/` via the shadcn CLI and customized in place.
**Customization approach:**
1. Install the shadcn/ui component (`npx shadcn@latest add button`).
2. The component lands in `src/shared/components/ui/button.tsx`.
3. Modify theme tokens in `globals.css` to match our color system.
4. Extend component variants if needed (e.g., adding a `brand` variant to `Button`).
5. Never wrap shadcn components in another abstraction layer unless adding substantial logic. Use them directly.
**Key shadcn/ui components in use:**
- `Button` -- primary actions, secondary actions, destructive actions, ghost navigation
- `Input`, `Textarea`, `Select` -- form controls
- `Card`, `CardHeader`, `CardContent`, `CardFooter` -- content containers
- `Dialog`, `AlertDialog` -- modal interactions (replaces `alert()`/`confirm()`)
- `DropdownMenu` -- context menus, overflow actions
- `Tabs` -- in-page mode switching (e.g., simple/advanced toggle)
- `Table` -- data display
- `Badge` -- status indicators, category labels
- `Tooltip` -- icon-only button labels
- `Separator` -- visual dividers
- `Switch`, `Checkbox` -- boolean toggles
- `Breadcrumb` -- navigation breadcrumbs in header
- `Sidebar` -- app shell navigation (shadcn sidebar component)
- `Sheet` -- mobile navigation drawer
---
## Layout System
### App Shell
The application uses a fixed sidebar + header + scrollable content area layout.
```
+--------------------------------------------------+
| [Sidebar] | [Header: breadcrumbs | theme | co] |
| |--------------------------------------|
| Nav | |
| items | [Content area] |
| | |
| grouped | max-w-6xl mx-auto px-6 py-6 |
| by | |
| category | |
| | |
+--------------------------------------------------+
```
### Sidebar
- Width: `16rem` (256px) expanded, `3rem` (48px) collapsed (icon-only mode).
- Collapsible via a toggle button at the bottom of the sidebar.
- Collapse state persisted to localStorage via a cookie (for SSR compatibility with `next-themes`).
- Background: `bg-slate-900` (dark) / `bg-white` (light) with a right border.
- Navigation items are grouped by category. Groups are driven by the module registry (`src/config/modules.ts`).
**Navigation structure:**
```
INSTRUMENTE (Tools)
- Semnatura Email
- Generator XML Word
ADMINISTRARE (Admin)
- Etichete Proiecte (Project Tags)
- Configurare (Settings)
(future groups added via module registry)
```
Each nav item displays:
- A Lucide icon (24x24)
- The module label (from `labels.ts`)
- An optional badge (e.g., count of saved configs)
Active state: teal left border + teal-tinted background (`bg-teal-500/10`).
### Header
Fixed at the top of the content area. Contains:
1. **Breadcrumbs** (left): Module group > Module name > Sub-page. Uses shadcn `Breadcrumb`.
2. **Company selector** (center-right): Dropdown to switch active company context. Drives branding colors and available configurations.
3. **Theme toggle** (right): Sun/Moon icon button. Toggles between dark/light via `next-themes`.
4. **User area** (far right, future): Avatar + dropdown for auth when implemented.
Height: `h-14` (56px). Bottom border separator.
### Content Area
- Container: `max-w-6xl mx-auto px-4 sm:px-6 py-6`
- For full-width tools (e.g., XML generator with preview): use `max-w-7xl` or remove max-width constraint.
- Content scrolls independently of sidebar and header.
---
## Card Patterns
### Primary Content Card
The standard content container. Used for forms, configuration panels, output displays.
```tsx
<Card>
<CardHeader>
<CardTitle>Configurare Semnatura</CardTitle>
<CardDescription>Completati datele pentru generarea semnaturii.</CardDescription>
</CardHeader>
<CardContent>
{/* form fields, content */}
</CardContent>
<CardFooter>
{/* action buttons */}
</CardFooter>
</Card>
```
Styling: `rounded-xl border` with theme-appropriate background. No drop shadows in light mode (border is sufficient). Subtle shadow in dark mode (`shadow-lg shadow-black/20`).
### Stat Card
For dashboard counters and KPIs.
```tsx
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-3xl font-bold leading-none mt-1">{value}</p>
</div>
<div className="rounded-lg bg-teal-500/10 p-3">
<Icon className="h-6 w-6 text-teal-500" />
</div>
</div>
</Card>
```
### Widget Card
For dashboard widgets (quick actions, recent items, external links).
Same structure as primary content card but with a fixed height and internal scroll if content overflows. Header includes an optional "View all" link.
---
## Form Patterns
### Input Groups
Each form field follows this structure:
```tsx
<div className="space-y-2">
<Label htmlFor="field-id">{labels.fieldName}</Label>
<Input id="field-id" {...props} />
<p className="text-xs text-muted-foreground">{labels.fieldHint}</p>
</div>
```
- Labels come from the label constants file (never hardcoded Romanian strings in JSX).
- Hints are optional `text-xs text-muted-foreground` paragraphs below the input.
- Required fields: no asterisk; instead, validation messages appear on submit.
### Form Layout
- Single column for simple forms.
- Two-column grid (`grid grid-cols-1 md:grid-cols-2 gap-4`) for forms with many short fields.
- Full-width fields (textareas, complex inputs) span both columns via `md:col-span-2`.
### Validation Display
Validation errors appear below the field as `text-xs text-destructive`:
```tsx
{error && <p className="text-xs text-destructive">{error}</p>}
```
Input border turns red on error: `border-destructive`.
### Romanian Labels
All user-facing text is sourced from `src/core/i18n/labels.ts`. Components reference label keys:
```tsx
import { labels } from '@/core/i18n/labels';
<Label>{labels.signature.fieldName}</Label>
```
Never write Romanian text directly in JSX. This ensures consistency, makes future i18n possible, and centralizes all copy for review.
### Label File Structure
```ts
// src/core/i18n/labels.ts
export const labels = {
common: {
save: 'Salveaza',
cancel: 'Anuleaza',
delete: 'Sterge',
download: 'Descarca',
copy: 'Copiaza',
generate: 'Genereaza',
reset: 'Reseteaza',
loading: 'Se incarca...',
noData: 'Nu exista date.',
confirm: 'Confirmare',
search: 'Cauta',
},
nav: {
tools: 'Instrumente',
admin: 'Administrare',
emailSignature: 'Semnatura Email',
xmlGenerator: 'Generator XML Word',
tagManager: 'Etichete Proiecte',
settings: 'Configurare',
},
signature: {
title: 'Configurator Semnatura Email',
fieldPrefix: 'Titulatura (prefix)',
fieldName: 'Nume si Prenume',
fieldRole: 'Functia',
fieldPhone: 'Telefon (format 07xxxxxxxx)',
sectionColors: 'Culori Text',
sectionLayout: 'Stil & Aranjare',
sectionOptions: 'Optiuni',
optionReply: 'Varianta simpla (fara logo/adresa)',
optionSuperReply: 'Super-simpla (doar nume/telefon)',
optionSvg: 'Foloseste imagini SVG (calitate maxima)',
exportHtml: 'Descarca HTML',
preview: 'Previzualizare Live',
},
xml: {
title: 'Generator XML pentru Word',
fieldNamespace: 'Namespace URI',
fieldRootElement: 'Element radacina',
fieldList: 'Lista de campuri (unul pe linie)',
modeSimple: 'Simplu',
modeAdvanced: 'Avansat',
categoryLabel: 'Categorii de date',
addCategory: 'Adauga categorie',
resetPreset: 'Reset categorie la preset',
clearFields: 'Curata campurile',
generateAll: 'Genereaza XML pentru toate categoriile',
downloadCurrent: 'Descarca XML categorie curenta',
downloadZip: 'Descarca ZIP cu toate XML-urile',
previewXml: 'XML categorie curenta',
previewXpath: 'XPaths categorie curenta',
},
// Additional module labels added here as modules are built.
} as const;
export type LabelKey = typeof labels;
```
---
## Table Patterns
Data tables use shadcn/ui `Table` components with the following conventions:
- **Header**: `font-medium text-sm text-muted-foreground`, no background color differentiation.
- **Rows**: alternating subtle background on hover (`hover:bg-muted/50`).
- **Sortable columns**: click header to toggle. Arrow indicator next to sorted column.
- **Filtering**: text input above the table, filters by visible columns.
- **Pagination**: below the table. "X of Y results" + page controls.
- **Empty state**: centered message inside the table area using `labels.common.noData`.
For complex data tables, use `@tanstack/react-table` as the headless engine with shadcn/ui table components for rendering.
---
## Dashboard Widgets
The dashboard home page (`/`) uses a responsive grid of widget cards.
### Layout
```tsx
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
{/* stat cards row */}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
{/* widget cards */}
</div>
```
### Widget Types
1. **Stat counters**: Number of saved signature configs, XML templates, etc. Uses Stat Card pattern.
2. **Recent items**: Last 5 saved/exported items across tools. List format inside a widget card.
3. **Quick actions**: Buttons to jump to common tasks ("New signature", "New XML template"). Grid of icon+label buttons.
4. **External tool links**: If legacy HTML tools are still accessible, link out to them with an "external" icon.
Widgets are defined in a registry and rendered dynamically. Each widget is a self-contained component that fetches its own data.
---
## Icon System
All icons use [Lucide React](https://lucide.dev/) (`lucide-react` package).
**Usage:**
```tsx
import { Mail, FileCode, Settings, ChevronRight } from 'lucide-react';
<Mail className="h-5 w-5" />
```
**Conventions:**
- Navigation icons: `h-5 w-5`
- Inline icons (next to text): `h-4 w-4`
- Stat card icons: `h-6 w-6`
- Button icons: `h-4 w-4 mr-2` (left of label) or `h-4 w-4 ml-2` (right of label)
- Icon-only buttons must have a `Tooltip` or `aria-label`.
**Do not** use SVG icons inline. Do not use icon fonts. Do not mix icon libraries.
---
## Spacing and Grid Conventions
All spacing uses Tailwind's default 4px-based scale.
| Context | Spacing | Tailwind |
|---|---|---|
| Between form fields | 8px | `space-y-2` |
| Between card sections | 16px | `space-y-4` |
| Card internal padding | 24px | `p-6` |
| Grid gap (cards) | 16px | `gap-4` |
| Page padding (desktop) | 24px | `px-6 py-6` |
| Page padding (mobile) | 16px | `px-4 py-4` |
Grid uses Tailwind's `grid` utility with responsive column counts:
- `grid-cols-1` (mobile)
- `md:grid-cols-2` (tablet)
- `lg:grid-cols-3` (desktop)
- `xl:grid-cols-4` (wide desktop, stat cards only)
---
## Responsive Breakpoints
Using Tailwind's default breakpoints:
| Breakpoint | Min-width | Typical device |
|---|---|---|
| `sm` | 640px | Large phone, small tablet |
| `md` | 768px | Tablet portrait |
| `lg` | 1024px | Tablet landscape, small desktop |
| `xl` | 1280px | Desktop |
| `2xl` | 1536px | Wide desktop |
**Behavior:**
- Below `lg`: sidebar collapses to a mobile drawer (shadcn `Sheet`).
- Below `md`: single-column content layout.
- At `lg` and above: sidebar visible, two-column form layouts.
- At `xl` and above: four-column stat card grid.
---
## Animation Guidelines
Animations are minimal and purposeful. This is a professional tool, not a marketing site.
**Allowed animations:**
- **Transitions on interactive elements**: `transition-colors duration-150` on buttons, links, nav items.
- **Sidebar collapse/expand**: `transition-[width] duration-200 ease-in-out`.
- **Card hover elevation** (optional): `transition-shadow duration-150`.
- **Dialog/sheet enter/exit**: Use shadcn/ui defaults (Radix UI built-in animations).
- **Skeleton loading**: `animate-pulse` on placeholder blocks during data loading.
**Not allowed:**
- Page transition animations.
- Bouncing, jiggling, or attention-seeking element animations.
- Parallax or scroll-linked effects.
- Auto-playing animations that loop indefinitely (except loading spinners).
- Transform-based hover effects on buttons (`hover:scale-105` and similar -- this was used in the legacy HTML tools but is too playful for the dashboard).
---
## Accessibility Baseline
- All interactive elements must be keyboard navigable.
- Focus rings: `focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`.
- Color contrast: minimum 4.5:1 for text, 3:1 for large text and UI elements (WCAG AA).
- Images and icons: `alt` text on informational images, `aria-hidden="true"` on decorative icons, `aria-label` on icon-only buttons.
- Form fields: every `<Input>` has an associated `<Label>` via `htmlFor`/`id`.
- Dialogs: use shadcn/ui `Dialog` which handles focus trapping and `aria-*` attributes.
- No information conveyed by color alone (always pair with text or icon).
- Reduced motion: respect `prefers-reduced-motion` by wrapping non-essential animations in `motion-safe:`.
---
## Summary: Design Tokens Quick Reference
```css
/* globals.css -- theme tokens (simplified) */
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--primary: 174 68% 41%; /* #22B5AB */
--primary-foreground: 0 0% 100%;
--muted: 210 40% 96%;
--muted-foreground: 215.4 16.3% 46.9%;
--border: 214.3 31.8% 91.4%;
--ring: 174 68% 41%;
--radius: 0.75rem;
}
.dark {
--background: 222.2 84% 2%; /* #020617 */
--foreground: 210 40% 93%;
--card: 222.2 84% 5%; /* #0f172a */
--card-foreground: 210 40% 93%;
--primary: 174 68% 41%;
--primary-foreground: 0 0% 100%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--border: 217.2 32.6% 17.5%;
--ring: 174 68% 41%;
}
```