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

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
.next
.git
.gitignore
.env
.env.local
.env.*.local
*.md
docs/
legacy/
.DS_Store

57
.env.example Normal file
View File

@@ -0,0 +1,57 @@
# =============================================================================
# ArchiTools - Environment Configuration
# =============================================================================
# Copy to .env.local for local development, or .env for Docker deployment.
# Variables prefixed with NEXT_PUBLIC_ are exposed to the browser.
# Application
NEXT_PUBLIC_APP_NAME=ArchiTools
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Storage adapter: 'localStorage' (default) | 'api' | 'minio'
NEXT_PUBLIC_STORAGE_ADAPTER=localStorage
# Feature flag overrides (set to 'true' or 'false')
# NEXT_PUBLIC_FLAG_MODULE_REGISTRATURA=true
# NEXT_PUBLIC_FLAG_MODULE_EMAIL_SIGNATURE=true
# NEXT_PUBLIC_FLAG_MODULE_WORD_XML=true
# NEXT_PUBLIC_FLAG_MODULE_PROMPT_GENERATOR=true
# NEXT_PUBLIC_FLAG_MODULE_DIGITAL_SIGNATURES=false
# NEXT_PUBLIC_FLAG_MODULE_PASSWORD_VAULT=false
# NEXT_PUBLIC_FLAG_MODULE_IT_INVENTORY=false
# NEXT_PUBLIC_FLAG_MODULE_ADDRESS_BOOK=false
# NEXT_PUBLIC_FLAG_MODULE_WORD_TEMPLATES=false
# NEXT_PUBLIC_FLAG_MODULE_TAG_MANAGER=true
# NEXT_PUBLIC_FLAG_MODULE_MINI_UTILITIES=false
# NEXT_PUBLIC_FLAG_MODULE_AI_CHAT=false
# Future: API storage backend
# STORAGE_API_URL=http://api.internal/storage
# Future: MinIO object storage
# MINIO_ENDPOINT=10.10.10.166:9003
# MINIO_ACCESS_KEY=
# MINIO_SECRET_KEY=
# MINIO_BUCKET=architools
# Future: Authentik SSO
# AUTHENTIK_URL=http://10.10.10.166:9100
# AUTHENTIK_CLIENT_ID=
# AUTHENTIK_CLIENT_SECRET=
# Future: N8N automation
# N8N_WEBHOOK_URL=http://10.10.10.166:5678/webhook
# External tool URLs (displayed in dashboard)
NEXT_PUBLIC_GITEA_URL=http://10.10.10.166:3002
# NEXT_PUBLIC_PORTAINER_URL=
NEXT_PUBLIC_MINIO_URL=http://10.10.10.166:9003
NEXT_PUBLIC_N8N_URL=http://10.10.10.166:5678
NEXT_PUBLIC_STIRLING_PDF_URL=http://10.10.10.166:8087
NEXT_PUBLIC_IT_TOOLS_URL=http://10.10.10.166:8085
NEXT_PUBLIC_FILEBROWSER_URL=http://10.10.10.166:8086
NEXT_PUBLIC_UPTIME_KUMA_URL=http://10.10.10.166:3001
NEXT_PUBLIC_NETDATA_URL=http://10.10.10.166:19999
NEXT_PUBLIC_DOZZLE_URL=http://10.10.10.166:9999
NEXT_PUBLIC_CROWDSEC_URL=http://10.10.10.166:8088
NEXT_PUBLIC_AUTHENTIK_URL=http://10.10.10.166:9100

45
.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
.claude/
context.md
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env
.env.local
.env.*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

33
Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
# Stage 1: Dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# 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 --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

23
components.json Normal file
View File

@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/shared/components",
"utils": "@/shared/lib/utils",
"ui": "@/shared/components/ui",
"lib": "@/shared/lib",
"hooks": "@/shared/hooks"
},
"registries": {}
}

22
docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
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
networks:
- proxy-network
labels:
- "com.centurylinklabs.watchtower.enable=true"
networks:
proxy-network:
external: true

606
docs/DATA-MODEL.md Normal file
View File

@@ -0,0 +1,606 @@
# Data Model Reference
> Canonical type definitions and data modeling rules for ArchiTools.
---
## Overview
This document defines the shared data types, entity conventions, and relationship patterns used across all ArchiTools modules. Module-specific types extend these base types. All types live in TypeScript; the runtime representation is JSON stored through the storage abstraction layer.
---
## Base Entity
Every storable entity in the system extends `BaseEntity`:
```typescript
// src/types/base.ts
interface BaseEntity {
/** Unique identifier. Generated via crypto.randomUUID() or nanoid. */
id: string;
/** ISO 8601 timestamp. Set once at creation, never modified. */
createdAt: string;
/** ISO 8601 timestamp. Updated on every mutation. */
updatedAt: string;
/** User ID of the creator. Omitted until auth is implemented. */
createdBy?: string;
/** Controls who can see this entity. */
visibility: Visibility;
/** Cross-module tags attached to this entity. Array of tag IDs. */
tags: string[];
/** Which company owns this entity. 'group' for shared/cross-company items. */
company: CompanyId;
}
```
### Rules
1. `id` is always a string. Use `crypto.randomUUID()` for generation. Never use sequential integers (they create conflicts across clients and are not merge-safe).
2. `createdAt` is set once during creation and must never change.
3. `updatedAt` is set on every save operation, including the initial creation.
4. `createdBy` is `undefined` until authentication is implemented. When auth ships, it becomes a required field on new entities. Existing entities with `undefined` are treated as created by the system.
5. `tags` stores tag IDs (not labels). Tags are resolved through the tag service.
6. `company` defaults to `'group'` for entities that are not company-specific.
---
## Company Model
```typescript
// src/types/company.ts
type CompanyId = 'beletage' | 'urban-switch' | 'studii-de-teren' | 'group';
interface Company {
id: CompanyId;
name: string; // Full legal name: "Beletage SRL"
shortName: string; // Display name: "Beletage"
cui: string; // Romanian CUI (tax ID)
address?: string; // Registered address
email?: string; // Primary contact email
phone?: string; // Primary phone number
}
```
### Company Registry
Defined as static configuration in `src/config/companies.ts`:
```typescript
export const COMPANIES: Record<CompanyId, Company> = {
beletage: {
id: 'beletage',
name: 'Beletage SRL',
shortName: 'Beletage',
cui: '...', // populated in config
},
'urban-switch': {
id: 'urban-switch',
name: 'Urban Switch SRL',
shortName: 'Urban Switch',
cui: '...',
},
'studii-de-teren': {
id: 'studii-de-teren',
name: 'Studii de Teren SRL',
shortName: 'Studii de Teren',
cui: '...',
},
group: {
id: 'group',
name: 'Grup Beletage',
shortName: 'Grup',
cui: '',
},
};
```
`'group'` is a virtual company representing the entire organization. Used for shared resources (templates, tags, prompts) that are not company-specific.
---
## Visibility and Roles
### Visibility
```typescript
// src/types/visibility.ts
type Visibility = 'all' | 'internal' | 'admin' | 'guest';
```
| Value | Who can see | Use case |
|---|---|---|
| `all` | Everyone including future external/guest users | Public templates, shared contacts |
| `internal` | All authenticated internal users | Most operational data |
| `admin` | Admin users only | Passwords, sensitive config |
| `guest` | Guest users and above | Curated external-facing views |
Until authentication is implemented, all data is treated as `internal`. The visibility field is stored but not enforced at the storage layer. Enforcement will be added at the API/middleware layer when auth ships.
### Roles
```typescript
// src/types/roles.ts
type Role = 'admin' | 'user' | 'viewer' | 'guest';
```
| Role | Permissions |
|---|---|
| `admin` | Full CRUD on all entities. Access to all modules. System configuration. |
| `user` | CRUD on own entities. Access to enabled modules. |
| `viewer` | Read-only access to entities matching their visibility level. |
| `guest` | Read-only access to `guest` and `all` visibility entities. Limited module access. |
### Future Auth Integration
When Authentik SSO is integrated:
```typescript
interface UserProfile {
id: string;
email: string;
name: string;
role: Role;
companies: CompanyId[]; // Companies this user belongs to
modules: string[]; // Explicitly allowed modules (empty = all for role)
}
```
Users may belong to multiple companies. Entity filtering will combine role, visibility, and company membership.
---
## Tag Model
Tags are a cross-module resource managed by the Tag Manager module but consumed everywhere.
```typescript
// src/types/tag.ts
interface Tag {
/** Unique tag ID. */
id: string;
/** Human-readable label. Romanian. Example: "076 Casa Copernicus" */
label: string;
/**
* Tag category for grouping.
* Examples: 'project', 'client', 'phase', 'type', 'priority', 'domain'
*/
category: TagCategory;
/** Display color. Hex string: "#3B82F6" */
color: string;
/**
* If set, this tag is only visible/usable within the specified modules.
* If undefined or empty, the tag is available globally.
*/
moduleScope?: string[];
/** Company this tag belongs to. 'group' for cross-company tags. */
company: CompanyId;
/** Whether this tag is active. Archived tags are hidden from selectors but preserved on entities. */
archived: boolean;
createdAt: string;
updatedAt: string;
}
type TagCategory =
| 'project'
| 'client'
| 'phase'
| 'type'
| 'priority'
| 'domain'
| 'custom';
```
### Project Reference Pattern
Tags with category `'project'` follow the ManicTime naming convention used in the office:
```
"{number} {project name}"
```
Examples:
- `"076 Casa Copernicus"`
- `"081 PUZ Sector 3"`
- `"092 Reabilitare Scoala Nr 5"`
The number is a sequential project identifier scoped per company. The tag label is the canonical reference; there is no separate `projectNumber` field.
**Parsing utility:**
```typescript
// src/lib/tags/utils.ts
interface ParsedProjectTag {
number: string; // "076"
name: string; // "Casa Copernicus"
raw: string; // "076 Casa Copernicus"
}
function parseProjectTag(label: string): ParsedProjectTag | null {
const match = label.match(/^(\d{3})\s+(.+)$/);
if (!match) return null;
return { number: match[1], name: match[2], raw: label };
}
```
### Tag Relationships
Tags create implicit relationships between entities across modules. For example:
- A registratura entry tagged `"076 Casa Copernicus"` is linked to the same project as a word template with the same tag.
- Searching by tag ID across all namespaces yields every entity related to that project.
This is the primary cross-module linking mechanism until a dedicated relational backend is introduced.
---
## Module-Specific Entity Types
Each module defines its own entity types that extend `BaseEntity`. Below is an overview of the primary entity per module.
### Registratura
```typescript
// src/modules/registratura/types.ts
interface RegistryEntry extends BaseEntity {
/** Sequential registry number per company per year. "B-2025-0042" */
registryNumber: string;
/** Entry or exit. */
direction: 'incoming' | 'outgoing';
/** Date the document was registered. ISO 8601 date (not datetime). */
registryDate: string;
/** Document title / description. */
title: string;
/** Sender (for incoming) or recipient (for outgoing). */
correspondent: string;
/** Number of physical pages, if applicable. */
pageCount?: number;
/** Reference to another registry entry (e.g., reply to incoming). */
referenceTo?: string;
/** Free-text notes. */
notes?: string;
/** Attached file references (future: MinIO object keys). */
attachments: string[];
}
```
### IT Inventory
```typescript
// src/modules/it-inventory/types.ts
type DeviceType = 'laptop' | 'desktop' | 'monitor' | 'printer' | 'router' |
'switch' | 'nas' | 'phone' | 'tablet' | 'peripheral' | 'other';
type DeviceStatus = 'active' | 'storage' | 'repair' | 'retired' | 'disposed';
interface InventoryItem extends BaseEntity {
name: string;
deviceType: DeviceType;
manufacturer?: string;
model?: string;
serialNumber?: string;
purchaseDate?: string;
warrantyExpiry?: string;
assignedTo?: string;
location?: string;
status: DeviceStatus;
specs?: Record<string, string>; // CPU, RAM, storage, etc.
notes?: string;
}
```
### Address Book
```typescript
// src/modules/address-book/types.ts
type ContactType = 'person' | 'company' | 'institution' | 'supplier';
interface AddressContact extends BaseEntity {
contactType: ContactType;
name: string;
organization?: string;
role?: string;
email?: string;
phone?: string;
address?: string;
city?: string;
county?: string;
country?: string;
cui?: string; // For companies/institutions
iban?: string;
bank?: string;
website?: string;
notes?: string;
favorite: boolean;
}
```
### Prompt Generator
```typescript
// src/modules/prompt-generator/types.ts
type PromptDomain = 'architecture' | 'legal' | 'technical' | 'gis' |
'rendering' | 'urbanism' | 'bim' | 'procurement' | 'administrative';
type PromptTargetAI = 'text' | 'image' | 'code' | 'review' | 'rewrite';
interface PromptTemplate extends BaseEntity {
name: string;
category: string;
domain: PromptDomain;
description: string;
targetAI: PromptTargetAI;
blocks: PromptBlock[];
variables: PromptVariable[];
outputMode: OutputMode;
providerProfile?: string;
version: number;
parentTemplateId?: string; // For cloned/forked templates
favorite: boolean;
}
```
### Email Signature
```typescript
// src/modules/email-signature/types.ts
interface SignatureConfig extends BaseEntity {
employeeName: string;
jobTitle: string;
email: string;
phone?: string;
mobile?: string;
/** Generated HTML output. */
generatedHtml: string;
}
```
### Digital Signatures
```typescript
// src/modules/digital-signatures/types.ts
type SignatureAssetType = 'signature' | 'stamp' | 'initials';
interface SignatureAsset extends BaseEntity {
name: string;
assetType: SignatureAssetType;
/** Base64-encoded PNG for localStorage. MinIO object key for API backend. */
imageData: string;
owner?: string;
notes?: string;
}
```
### Password Vault
```typescript
// src/modules/password-vault/types.ts
interface VaultEntry extends BaseEntity {
service: string;
url?: string;
username: string;
/** Stored as plaintext in localStorage (demo-grade security). Encrypted in API backend. */
password: string;
notes?: string;
category?: string;
lastRotated?: string;
}
```
> **Security note:** The localStorage adapter stores passwords in plaintext. This is acceptable only for internal demo use. The API backend must encrypt the `password` field at rest.
### Word Templates / Word XML Generators
```typescript
// src/modules/word-templates/types.ts
type TemplateCategory = 'contract' | 'offer' | 'report' | 'letter' |
'header' | 'certificate' | 'minutes' | 'other';
interface WordTemplate extends BaseEntity {
name: string;
category: TemplateCategory;
description?: string;
/** File reference: base64 for localStorage, MinIO key for API backend. */
fileData: string;
fileName: string;
fileSize: number;
version: number;
}
```
---
## Naming Conventions
### Type Names
| Convention | Example | Used For |
|---|---|---|
| PascalCase | `RegistryEntry` | Interfaces, type aliases, classes |
| camelCase | `registryNumber` | Properties, variables, functions |
| UPPER_SNAKE_CASE | `COMPANIES` | Constants, static config objects |
| kebab-case | `'urban-switch'` | String literal union members (IDs, slugs) |
### Entity Type Naming
- Main entity per module: descriptive noun (`RegistryEntry`, `InventoryItem`, `AddressContact`).
- Do not prefix with module name unless ambiguity exists.
- Supporting types (enums, sub-objects): use descriptive names scoped by context (`DeviceType`, `ContactType`).
- Union string literal types: use the values themselves, not a wrapper. Prefer `type Direction = 'incoming' | 'outgoing'` over an enum.
### File Naming
- Type definition files: `types.ts` in the module root.
- Shared types: `src/types/{name}.ts`.
- Never put types in a barrel `index.ts` that also exports components (causes circular dependency issues with lazy loading).
---
## Schema Versioning
### Strategy
Each module's data schema has a version number tracked in the namespace metadata:
```typescript
// Stored at key "_meta" within each namespace
interface NamespaceMeta {
schemaVersion: number;
lastMigration: string;
itemCount: number;
}
```
### Migration Registration
```typescript
// src/modules/registratura/migrations.ts
import { defineMigrations } from '@/lib/storage/migrations';
export const registraturaMigrations = defineMigrations('registratura', [
{
version: 2,
description: 'Add attachments array to registry entries',
up: (data: Record<string, unknown>) => {
for (const [key, value] of Object.entries(data)) {
if (key === '_meta') continue;
const entry = value as Record<string, unknown>;
if (!entry.attachments) {
entry.attachments = [];
}
}
return data;
},
},
{
version: 3,
description: 'Rename sender field to correspondent',
up: (data: Record<string, unknown>) => {
for (const [key, value] of Object.entries(data)) {
if (key === '_meta') continue;
const entry = value as Record<string, unknown>;
if ('sender' in entry) {
entry.correspondent = entry.sender;
delete entry.sender;
}
}
return data;
},
},
]);
```
### Migration Execution
On module initialization, the storage layer:
1. Reads `_meta.schemaVersion` for the module's namespace.
2. Compares to the latest registered migration version.
3. If behind, runs each migration sequentially (`version N` to `version N+1`).
4. Updates `_meta.schemaVersion` after each successful migration.
5. If any migration fails, the process halts and logs an error. The module should display a degraded state rather than corrupting data.
---
## Entity Relationships
ArchiTools uses a flat document model (not relational). Relationships are expressed through:
### 1. Tags (Primary Cross-Module Link)
Entities share project tags. Querying all namespaces for a tag ID returns every entity related to that project.
```
RegistryEntry.tags: ["tag-076-casa-copernicus"]
WordTemplate.tags: ["tag-076-casa-copernicus"]
PromptTemplate.tags: ["tag-076-casa-copernicus"]
```
### 2. Direct References
Some entities reference others by ID within the same namespace:
- `RegistryEntry.referenceTo` points to another registry entry ID.
- `PromptTemplate.parentTemplateId` points to the template it was cloned from.
### 3. Company Scoping
The `company` field on every entity enables filtering by company. A user at Beletage sees Beletage and group entities; never Urban Switch entities (unless their role grants cross-company access).
### 4. Future: Explicit Project Entity
When the platform matures, a `Project` entity may be introduced:
```typescript
interface Project extends BaseEntity {
number: string; // "076"
name: string; // "Casa Copernicus"
company: CompanyId;
status: 'active' | 'completed' | 'archived';
client?: string; // AddressContact ID
startDate?: string;
endDate?: string;
}
```
At that point, tags with category `'project'` would reference a `Project.id`, and the project number/name would be the single source of truth. Until then, the tag label is the canonical project identifier.
---
## Shared Type Exports
All shared types are re-exported from `src/types/index.ts`:
```typescript
// src/types/index.ts
export type { BaseEntity } from './base';
export type { CompanyId, Company } from './company';
export type { Visibility } from './visibility';
export type { Role, UserProfile } from './roles';
export type { Tag, TagCategory } from './tag';
```
Module-specific types are not exported from this barrel. They are imported directly from the module:
```typescript
import type { RegistryEntry } from '@/modules/registratura/types';
```
This keeps the shared type surface minimal and avoids pulling module code into unrelated bundles.

563
docs/REPO-STRUCTURE.md Normal file
View File

@@ -0,0 +1,563 @@
# ArchiTools — Repository Structure Reference
This document describes every planned directory and key file in the ArchiTools repository. It serves as the canonical reference for where code belongs, what each directory contains, and what naming conventions to follow.
---
## Complete Directory Tree
```
ArchiTools/
├── docs/ # Internal technical documentation
│ ├── architecture/ # Architecture design documents
│ │ └── SYSTEM-ARCHITECTURE.md # Platform architecture overview
│ ├── guides/ # Developer and operational guides
│ │ ├── MODULE-DEVELOPMENT.md # How to build a new module
│ │ ├── STORAGE-LAYER.md # Storage abstraction usage
│ │ ├── FEATURE-FLAGS.md # Feature flag system guide
│ │ ├── HTML-INTEGRATION.md # Legacy HTML tool integration
│ │ ├── DOCKER-DEPLOYMENT.md # Build, deploy, and operate
│ │ ├── CODING-STANDARDS.md # Code style and conventions
│ │ └── UI-DESIGN-SYSTEM.md # Theme, components, layout rules
│ └── modules/ # Module-specific design docs
│ ├── REGISTRATURA.md # Registratura module design
│ ├── PROMPT-GENERATOR.md # Prompt Generator module design
│ └── ... # One doc per complex module
├── src/ # All application source code
│ ├── app/ # Next.js App Router (routing layer)
│ │ ├── layout.tsx # Root layout
│ │ ├── page.tsx # Dashboard home page
│ │ ├── globals.css # Tailwind directives + global styles
│ │ ├── not-found.tsx # Custom 404 page
│ │ └── (modules)/ # Route group for all modules
│ │ ├── registratura/
│ │ │ ├── page.tsx # Module list/main view
│ │ │ ├── layout.tsx # Module-level layout (optional)
│ │ │ └── [id]/
│ │ │ └── page.tsx # Detail/edit view
│ │ ├── email-signature/
│ │ │ └── page.tsx
│ │ ├── word-xml/
│ │ │ └── page.tsx
│ │ ├── prompt-generator/
│ │ │ └── page.tsx
│ │ ├── digital-signatures/
│ │ │ └── page.tsx
│ │ ├── password-vault/
│ │ │ └── page.tsx
│ │ ├── it-inventory/
│ │ │ └── page.tsx
│ │ ├── address-book/
│ │ │ └── page.tsx
│ │ ├── word-templates/
│ │ │ └── page.tsx
│ │ ├── tag-manager/
│ │ │ └── page.tsx
│ │ ├── mini-utilities/
│ │ │ └── page.tsx
│ │ └── ai-chat/
│ │ └── page.tsx
│ │
│ ├── core/ # Platform core systems
│ │ ├── module-registry/
│ │ │ ├── registry.ts # Module catalog and lookup functions
│ │ │ ├── types.ts # ModuleConfig, ModuleCategory types
│ │ │ └── index.ts # Public API
│ │ ├── feature-flags/
│ │ │ ├── flag-service.ts # Flag resolution logic
│ │ │ ├── flag-provider.tsx # React context provider
│ │ │ ├── use-feature-flag.ts # Hook for flag checks
│ │ │ ├── feature-gate.tsx # Conditional render component
│ │ │ ├── types.ts # FeatureFlag, FlagScope types
│ │ │ └── index.ts # Public API
│ │ ├── storage/
│ │ │ ├── storage-service.ts # StorageService interface definition
│ │ │ ├── storage-provider.tsx # React context provider
│ │ │ ├── use-storage.ts # Hook for storage access
│ │ │ ├── adapters/
│ │ │ │ ├── local-storage.ts # localStorage adapter (current)
│ │ │ │ ├── minio.ts # MinIO adapter (planned)
│ │ │ │ └── database.ts # Database adapter (planned)
│ │ │ ├── types.ts # StorageAdapter, StorageOptions types
│ │ │ └── index.ts # Public API
│ │ ├── tagging/
│ │ │ ├── tag-service.ts # Tag CRUD and query logic
│ │ │ ├── use-tags.ts # Hook for tag operations
│ │ │ ├── types.ts # Tag, TagCategory types
│ │ │ └── index.ts # Public API
│ │ ├── i18n/
│ │ │ ├── i18n-provider.tsx # React context provider
│ │ │ ├── use-label.ts # Hook for label access
│ │ │ ├── label.tsx # <Label> component
│ │ │ ├── locales/
│ │ │ │ └── ro.ts # Romanian labels (current)
│ │ │ ├── types.ts # LabelKey, Locale types
│ │ │ └── index.ts # Public API
│ │ ├── theme/
│ │ │ ├── theme-provider.tsx # Theme context with dark/light toggle
│ │ │ ├── use-theme.ts # Hook for theme access
│ │ │ ├── tokens.ts # Design token definitions
│ │ │ └── index.ts # Public API
│ │ └── auth/
│ │ ├── auth-provider.tsx # Auth context provider (stub)
│ │ ├── use-auth.ts # Hook for auth state
│ │ ├── types.ts # User, UserRole, AuthContext types
│ │ └── index.ts # Public API
│ │
│ ├── modules/ # Module business logic
│ │ ├── registratura/
│ │ │ ├── components/
│ │ │ │ ├── entry-form.tsx # Registry entry form
│ │ │ │ ├── entry-table.tsx # Entry list table
│ │ │ │ └── entry-card.tsx # Entry summary card
│ │ │ ├── hooks/
│ │ │ │ ├── use-entries.ts # Entry CRUD state management
│ │ │ │ └── use-entry-filters.ts
│ │ │ ├── services/
│ │ │ │ ├── entry-service.ts # Entry business logic
│ │ │ │ └── validation.ts # Entry validation rules
│ │ │ ├── types.ts # RegistryEntry, EntryStatus, etc.
│ │ │ ├── config.ts # Module registration config
│ │ │ └── index.ts # Public barrel export
│ │ ├── email-signature/
│ │ │ ├── components/
│ │ │ ├── hooks/
│ │ │ ├── services/
│ │ │ ├── types.ts
│ │ │ ├── config.ts
│ │ │ └── index.ts
│ │ ├── word-xml/
│ │ │ ├── components/
│ │ │ ├── hooks/
│ │ │ ├── services/
│ │ │ ├── types.ts
│ │ │ ├── config.ts
│ │ │ └── index.ts
│ │ ├── prompt-generator/
│ │ │ ├── components/
│ │ │ ├── hooks/
│ │ │ ├── services/
│ │ │ ├── templates/ # Prompt template data files
│ │ │ ├── types.ts
│ │ │ ├── config.ts
│ │ │ └── index.ts
│ │ ├── digital-signatures/
│ │ │ ├── components/
│ │ │ ├── hooks/
│ │ │ ├── services/
│ │ │ ├── types.ts
│ │ │ ├── config.ts
│ │ │ └── index.ts
│ │ ├── password-vault/
│ │ │ ├── components/
│ │ │ ├── hooks/
│ │ │ ├── services/
│ │ │ ├── types.ts
│ │ │ ├── config.ts
│ │ │ └── index.ts
│ │ ├── it-inventory/
│ │ │ ├── components/
│ │ │ ├── hooks/
│ │ │ ├── services/
│ │ │ ├── types.ts
│ │ │ ├── config.ts
│ │ │ └── index.ts
│ │ ├── address-book/
│ │ │ ├── components/
│ │ │ ├── hooks/
│ │ │ ├── services/
│ │ │ ├── types.ts
│ │ │ ├── config.ts
│ │ │ └── index.ts
│ │ ├── word-templates/
│ │ │ ├── components/
│ │ │ ├── hooks/
│ │ │ ├── services/
│ │ │ ├── types.ts
│ │ │ ├── config.ts
│ │ │ └── index.ts
│ │ ├── tag-manager/
│ │ │ ├── components/
│ │ │ ├── hooks/
│ │ │ ├── services/
│ │ │ ├── types.ts
│ │ │ ├── config.ts
│ │ │ └── index.ts
│ │ ├── mini-utilities/
│ │ │ ├── components/
│ │ │ ├── hooks/
│ │ │ ├── services/
│ │ │ ├── types.ts
│ │ │ ├── config.ts
│ │ │ └── index.ts
│ │ └── ai-chat/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── services/
│ │ ├── types.ts
│ │ ├── config.ts
│ │ └── index.ts
│ │
│ ├── shared/ # Shared UI components and utilities
│ │ ├── components/
│ │ │ ├── ui/ # shadcn/ui primitives
│ │ │ │ ├── button.tsx
│ │ │ │ ├── input.tsx
│ │ │ │ ├── card.tsx
│ │ │ │ ├── dialog.tsx
│ │ │ │ ├── table.tsx
│ │ │ │ ├── badge.tsx
│ │ │ │ ├── select.tsx
│ │ │ │ ├── tabs.tsx
│ │ │ │ ├── toast.tsx
│ │ │ │ ├── dropdown-menu.tsx
│ │ │ │ └── ... # Added as needed via shadcn CLI
│ │ │ ├── layout/ # Application shell components
│ │ │ │ ├── app-shell.tsx # Main layout wrapper
│ │ │ │ ├── sidebar.tsx # Navigation sidebar
│ │ │ │ ├── header.tsx # Top header bar
│ │ │ │ ├── footer.tsx # Optional footer
│ │ │ │ └── breadcrumbs.tsx # Breadcrumb navigation
│ │ │ └── common/ # Reusable domain components
│ │ │ ├── tag-selector.tsx # Tag picker (uses tagging core)
│ │ │ ├── company-badge.tsx # Company indicator
│ │ │ ├── data-table.tsx # Generic sortable/filterable table
│ │ │ ├── search-input.tsx # Debounced search field
│ │ │ ├── confirm-dialog.tsx # Confirmation modal
│ │ │ ├── empty-state.tsx # Empty state placeholder
│ │ │ ├── loading-state.tsx # Loading skeleton
│ │ │ └── copy-button.tsx # Copy-to-clipboard button
│ │ ├── hooks/ # Shared React hooks
│ │ │ ├── use-debounce.ts # Debounce value hook
│ │ │ ├── use-clipboard.ts # Clipboard access hook
│ │ │ ├── use-media-query.ts # Responsive breakpoint hook
│ │ │ └── use-local-state.ts # Local component state with sync
│ │ └── lib/ # Shared utility functions
│ │ ├── utils.ts # General utilities (cn(), formatDate, etc.)
│ │ ├── validators.ts # Common validation functions
│ │ └── constants.ts # Shared constants
│ │
│ └── config/ # Global application configuration
│ ├── modules.ts # Module registry (imports all module configs)
│ ├── flags.ts # Feature flag defaults
│ ├── navigation.ts # Navigation structure builder
│ ├── companies.ts # Company definitions (Beletage, Urban Switch, Studii de Teren)
│ └── external-tools.ts # External tool links (Stirling PDF, IT-Tools, etc.)
├── legacy/ # Original standalone HTML tools
│ ├── emailsignature/
│ │ └── emailsignature-config.html
│ ├── wordXMLgenerator/
│ │ ├── word-xml-generator-basic.html
│ │ ├── word-xml-generator-medium.html
│ │ └── word-xml-generator-advanced.html
│ └── manicprojects/
│ └── current manic time Tags.txt
├── public/ # Static assets served at root
│ ├── favicon.ico
│ ├── logo.svg # Platform logo
│ └── assets/
│ ├── company-logos/ # Company logo variants
│ └── icons/ # Custom icons if needed
├── Dockerfile # Multi-stage production build
├── docker-compose.yml # Service definition for deployment
├── .env.example # Environment variable template
├── .env.local # Local dev overrides (gitignored)
├── next.config.ts # Next.js configuration
├── tailwind.config.ts # Tailwind theme and plugin config
├── tsconfig.json # TypeScript compiler configuration
├── postcss.config.mjs # PostCSS config (Tailwind plugin)
├── package.json # Dependencies and scripts
├── package-lock.json # Dependency lock file
├── components.json # shadcn/ui configuration
├── .gitignore # Git ignore rules
├── .eslintrc.json # ESLint configuration
├── .prettierrc # Prettier formatting rules
├── CONTEXT.md # Project context and requirements
└── LICENSE # License file (if applicable)
```
---
## Directory Descriptions
### `docs/`
Internal technical documentation. Not shipped in the Docker image. Organized into three subdirectories:
- **`architecture/`** — System-level design documents. Describes the platform as a whole: architecture overview, data flow, deployment model, security boundaries. These documents change infrequently and are reviewed before major structural changes.
- **`guides/`** — Developer and operational guides. Practical, how-to oriented. Covers module development, storage usage, feature flag configuration, Docker deployment, coding standards, and UI design rules. These are the documents developers consult daily.
- **`modules/`** — Module-specific design documents. One file per complex module. Describes the module's data model, UI structure, business rules, integration points, and edge cases. Simple modules may not need a dedicated doc.
### `src/app/`
Next.js App Router directory. Contains **only routing concerns**: page components, layouts, and metadata. No business logic lives here. Page files are thin wrappers that import and compose components from `src/modules/` and `src/shared/`.
- **`layout.tsx`** — Root layout. Wraps the entire application in the provider stack (Theme, i18n, Storage, FeatureFlags, Auth). Renders the AppShell (sidebar + header + content area). This file should rarely change after initial setup.
- **`page.tsx`** — Dashboard home. Renders the widget grid with module entry points, quick actions, and infrastructure service links.
- **`globals.css`** — Tailwind's `@tailwind` directives, CSS custom properties for theming, and minimal global styles. No component-specific CSS.
- **`not-found.tsx`** — Custom 404 page displayed when navigating to disabled modules or invalid routes.
- **`(modules)/`** — Route group. The parentheses mean this directory does not add a URL segment. All module routes live here, keeping them visually grouped without nesting URLs. Each module subdirectory contains at minimum a `page.tsx`. Complex modules may include `layout.tsx` for module-level navigation (tabs, sub-routes) and dynamic route segments (`[id]/`).
### `src/core/`
Platform core systems. These are infrastructure services used by all modules. Core systems do not contain business logic for any specific module. They provide capabilities that modules consume.
- **`module-registry/`** — Maintains the catalog of all registered modules. Provides lookup functions (by ID, route, category), filtering (by enabled state, role, visibility), and the ordered list used by navigation. The registry reads from `src/config/modules.ts`.
- **`feature-flags/`** — Controls runtime activation of modules and features. Includes the flag resolution service (environment variable > config > default), React context provider, `useFeatureFlag()` hook, and `<FeatureGate>` render guard component. Flags are defined in `src/config/flags.ts`.
- **`storage/`** — Persistence abstraction layer. Defines the `StorageService` interface and provides adapter implementations. The `adapters/` subdirectory contains one file per storage backend. The active adapter is selected at startup based on environment configuration. All module data flows through this service.
- **`tagging/`** — Cross-module tagging system. Provides tag CRUD operations, tag association with entities, and tag-based querying. Tags are stored via the storage abstraction. Multiple modules (Registratura, Prompt Generator, Digital Signatures, etc.) use this service to categorize and cross-reference their entities.
- **`i18n/`** — Internationalization system. Currently Romanian-only, structured for future multi-language support. Labels are organized by namespace (one per module + common). The `locales/` subdirectory contains one file per language. Components access labels via `useLabel()` hook or `<Label>` component.
- **`theme/`** — Dark/light theme system. Provides the theme context, toggle hook, and design token definitions. Theme preference is persisted in storage. Tokens define colors, spacing, and typography values consumed by Tailwind and component styles.
- **`auth/`** — Authentication and authorization stub. Defines the `AuthContext` interface (`user`, `role`, `permissions`, `company`). Currently returns a default admin user. When Authentik SSO integration is implemented, this module will resolve real identity from OIDC tokens. The interface remains stable; only the provider implementation changes.
### `src/modules/`
Module business logic. Each subdirectory is a self-contained module with its own components, hooks, services, types, and configuration. Modules are the primary unit of functionality in ArchiTools.
**Standard module structure:**
| File/Directory | Purpose |
|----------------|---------|
| `components/` | React components specific to this module. Not shared with other modules. |
| `hooks/` | React hooks encapsulating module state and logic. Hooks call services and expose state to components. |
| `services/` | Pure business logic functions. Validation, transformation, computation. No React dependencies. |
| `types.ts` | TypeScript interfaces and types for this module's domain. |
| `config.ts` | `ModuleConfig` object exported for the module registry. Contains ID, name, route, icon, category, feature flag key, and other metadata. |
| `index.ts` | Barrel export. Exposes the module's public API: config, key types, and any components/hooks that other parts of the app need (primarily used by route pages). |
**Module-specific additions:**
Some modules extend the standard structure:
- `prompt-generator/templates/` — Data-driven prompt template definitions (JSON/TS files, not UI components).
- Modules with sub-views may have a `views/` directory alongside `components/` for page-level compositions.
### `src/shared/`
Shared UI components, hooks, and utilities used across multiple modules. Nothing module-specific lives here.
- **`components/ui/`** — shadcn/ui primitive components. These are added via the `npx shadcn@latest add` CLI command and may be customized. They provide the atomic building blocks: buttons, inputs, cards, dialogs, tables, badges, selects, etc. Files in this directory are generated and should be modified sparingly.
- **`components/layout/`** — Application shell components. The sidebar, header, footer, breadcrumbs, and the main app-shell wrapper. These components compose the persistent chrome around module content. They read from the module registry and navigation config to build menu items dynamically.
- **`components/common/`** — Reusable domain-aware components shared across modules. Unlike `ui/` (which are generic primitives), these components encode ArchiTools-specific patterns: tag selectors, company badges, data tables with standard filtering, confirmation dialogs, empty/loading states. Any component needed by two or more modules belongs here.
- **`hooks/`** — Shared React hooks for common browser interactions: debouncing, clipboard access, media queries, local component state synchronization. Module-specific hooks belong in the module's `hooks/` directory, not here.
- **`lib/`** — Pure utility functions and constants. Includes the `cn()` class merge utility (standard shadcn/ui pattern), date formatting, common validators, and shared constants (e.g., date formats, pagination defaults).
### `src/config/`
Global application configuration. Static configuration data that drives runtime behavior.
- **`modules.ts`** — Central module registry data. Imports `config.ts` from every module and exports the complete array. This is the single file that determines which modules the platform knows about.
- **`flags.ts`** — Feature flag default values. Maps flag keys to their default `enabled` state and scope. Environment variables can override these at runtime.
- **`navigation.ts`** — Navigation structure builder. Reads from the module registry, applies feature flag filtering, groups by category, and produces the ordered navigation tree consumed by the sidebar.
- **`companies.ts`** — Company definitions for Beletage SRL, Urban Switch SRL, and Studii de Teren SRL. Contains company ID, name, short name, color, logo path, and any per-company configuration. Used by the Registratura, Email Signature Generator, Address Book, and other multi-company modules.
- **`external-tools.ts`** — Links to external infrastructure services (Stirling PDF, IT-Tools, Filebrowser, N8N, Portainer, etc.) displayed on the dashboard and optionally in navigation.
### `legacy/`
Original standalone HTML tools that predate the ArchiTools platform. These files are preserved for reference and serve as the specification for React module reimplementation. They are not served by the Next.js application.
- **`emailsignature/`** — Email signature generator. Single HTML file with inline CSS/JS. Generates HTML email signatures for company employees.
- **`wordXMLgenerator/`** — Word XML content control binding generators. Three complexity tiers (basic, medium, advanced). Generate XML binding files for Microsoft Word templates.
- **`manicprojects/`** — ManicTime project tag definitions. Text file with time tracking tag categories.
When a legacy tool is fully reimplemented as a React module, the legacy file remains in this directory for archival reference. It is never deleted.
### `public/`
Static assets served at the root URL path by Next.js. Contains the favicon, platform logo, company logos, and any custom icon assets. Keep this directory minimal; most assets should be imported via the build pipeline for optimization.
### Root Configuration Files
| File | Purpose |
|------|---------|
| `Dockerfile` | Multi-stage build: deps install, Next.js build, minimal runtime image. Produces a standalone Next.js server on Node.js Alpine. |
| `docker-compose.yml` | Single-service definition for ArchiTools. References `.env` for environment variables. Defines port mapping, restart policy, and optional volume mounts. |
| `.env.example` | Template for environment variables. Committed to the repo. Documents every variable with comments. Developers copy to `.env.local` for local development. |
| `next.config.ts` | Next.js configuration. Enables standalone output mode (required for Docker), configures image domains, and sets any required headers. |
| `tailwind.config.ts` | Tailwind CSS configuration. Extends the default theme with ArchiTools design tokens, custom colors, and font settings. References content paths for tree-shaking. |
| `tsconfig.json` | TypeScript configuration. Sets strict mode, path aliases (`@/` maps to `src/`), and App Router-compatible settings. |
| `postcss.config.mjs` | PostCSS configuration. Loads the Tailwind CSS plugin. |
| `package.json` | Project metadata, dependencies, and scripts (`dev`, `build`, `start`, `lint`, `format`). |
| `components.json` | shadcn/ui configuration. Defines the component install path (`src/shared/components/ui`), style settings, and Tailwind config path. |
| `.gitignore` | Ignores `node_modules/`, `.next/`, `.env.local`, `.env`, build output, OS files. |
| `.eslintrc.json` | ESLint rules. Extends Next.js defaults with TypeScript-specific rules. |
| `.prettierrc` | Prettier formatting configuration. Single quotes, trailing commas, 2-space indent, 100 character line width. |
| `CONTEXT.md` | Project context and requirements document. The original specification that drives all architecture decisions. |
---
## Directory Conventions
### 1. Separation of Routing and Logic
The `src/app/` directory contains **only routing and layout files**. All business logic, domain components, hooks, and services live in `src/modules/` or `src/shared/`. Route page files are thin: they import a module's components, check feature flags, and render.
```typescript
// src/app/(modules)/registratura/page.tsx — correct (thin wrapper)
import { EntryTable } from '@/modules/registratura';
import { FeatureGate } from '@/core/feature-flags';
export default function RegistraturaPage() {
return (
<FeatureGate flag="module.registratura">
<EntryTable />
</FeatureGate>
);
}
```
### 2. Module Self-Containment
Each module directory under `src/modules/` must be fully self-contained. If you delete a module's directory and remove its entry from `src/config/modules.ts`, the application must still build and run without errors. This means:
- No other module imports from this module's internals
- No shared component depends on a specific module
- The module's route page is guarded by a feature flag
### 3. Core Systems Are Not Modules
The `src/core/` directory contains platform infrastructure, not business modules. Core systems are always available regardless of feature flags. They do not have route pages. They provide services consumed by modules and the shell.
### 4. Shared Components Require Multi-Module Use
A component moves to `src/shared/components/common/` only when it is needed by two or more modules. Components used by a single module stay in that module's `components/` directory, even if they seem generic. Premature extraction creates coupling.
### 5. Config Files Are Declarative
Files in `src/config/` are pure data and simple builder functions. They do not contain business logic, side effects, or React components. They export configuration objects and arrays consumed by core systems.
---
## File Naming Rules
### General Rules
| Rule | Convention | Example |
|------|-----------|---------|
| File names | `kebab-case` | `entry-form.tsx`, `use-entries.ts` |
| Directory names | `kebab-case` | `module-registry/`, `email-signature/` |
| React components | `PascalCase` export, `kebab-case` filename | `entry-form.tsx` exports `EntryForm` |
| Hooks | `use-` prefix, `camelCase` export | `use-entries.ts` exports `useEntries` |
| Services | `-service` suffix | `entry-service.ts`, `tag-service.ts` |
| Type files | `types.ts` (one per module/core system) | `src/modules/registratura/types.ts` |
| Config files | `config.ts` (module) or descriptive name (global) | `config.ts`, `flags.ts` |
| Barrel exports | `index.ts` | Every module and core system has one |
| Test files | `.test.ts` / `.test.tsx` co-located | `entry-service.test.ts` |
### Specific Patterns
**React component files:**
```
kebab-case.tsx → exports PascalCase component
entry-form.tsx → export function EntryForm() {}
confirm-dialog.tsx → export function ConfirmDialog() {}
```
**Hook files:**
```
use-kebab-case.ts → exports camelCase hook
use-entries.ts → export function useEntries() {}
use-feature-flag.ts → export function useFeatureFlag() {}
```
**Service files:**
```
kebab-case-service.ts → exports named functions
entry-service.ts → export function getEntries() {}
tag-service.ts → export function createTag() {}
```
**Provider files (React context):**
```
kebab-case-provider.tsx → exports PascalCase provider component
theme-provider.tsx → export function ThemeProvider() {}
storage-provider.tsx → export function StorageProvider() {}
```
**Type files:**
```
types.ts → exports interfaces and type aliases
No default exports in type files
One types.ts per module or core system
```
**Config files:**
```
config.ts → module config (in module directory)
modules.ts → global module registry (in src/config/)
flags.ts → global feature flags (in src/config/)
```
### Import Aliases
The `tsconfig.json` defines a path alias:
```json
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}
```
All imports use the `@/` alias. Never use relative paths that traverse above the current module.
```typescript
// Correct
import { StorageService } from '@/core/storage';
import { Button } from '@/shared/components/ui/button';
import { useEntries } from '@/modules/registratura/hooks/use-entries';
// Incorrect
import { StorageService } from '../../../core/storage';
import { something } from '@/modules/other-module/internal-thing';
```
### Language in Code
- All file names: **English**
- All variable, function, class, and type names: **English**
- All code comments: **English**
- All UI-facing strings: **Romanian** (accessed via i18n labels, never hardcoded in components)
- All documentation: **English**
---
## Adding a New Module — Checklist
1. Create `src/modules/[module-name]/` with the standard structure (`components/`, `hooks/`, `services/`, `types.ts`, `config.ts`, `index.ts`)
2. Define `ModuleConfig` in `config.ts`
3. Add the module config import to `src/config/modules.ts`
4. Add the feature flag to `src/config/flags.ts`
5. Create the route directory `src/app/(modules)/[module-name]/page.tsx`
6. Wrap the page content in `<FeatureGate>`
7. Add Romanian labels to `src/core/i18n/locales/ro.ts` under the module namespace
8. If complex, create a design doc in `docs/modules/[MODULE-NAME].md`

View File

@@ -0,0 +1,572 @@
# Feature Flag System
> ArchiTools internal architecture reference -- feature flag design, implementation, and usage patterns.
---
## Overview
The feature flag system controls which modules and capabilities are available at runtime. It serves three purposes:
1. **Module gating** -- a module whose flag is disabled is never imported, never bundled into the active page, and never visible in navigation.
2. **Incremental rollout** -- experimental features can be enabled for specific users or environments without a separate deployment.
3. **Operational control** -- system flags allow disabling capabilities (e.g., AI features) without a code change.
Flags are defined statically in `src/config/flags.ts`, consumed via a React context provider, and checked with a hook or a gate component. There is no remote flag service today; the system is designed so one can be added without changing consumer code.
---
## Flag Definition Model
```typescript
// src/types/flags.ts
type FlagCategory = 'module' | 'experimental' | 'system';
interface FeatureFlag {
key: string; // unique identifier, matches module's featureFlag field
enabled: boolean; // default state
label: string; // Romanian display name
description: string; // Romanian description of what this flag controls
category: FlagCategory; // classification for the admin UI
requiredRole?: Role; // minimum role to see this feature (future use)
dependencies?: string[]; // other flag keys that must be enabled for this flag to take effect
overridable: boolean; // whether this flag can be toggled at runtime via admin panel
}
```
### Field Details
| Field | Notes |
|---|---|
| `key` | Convention: `module.[module-id]` for module flags, `exp.[feature]` for experimental, `sys.[capability]` for system. |
| `enabled` | The compiled default. This is what ships. Can be overridden per-environment via env vars or at runtime. |
| `dependencies` | If flag A depends on flag B, enabling A while B is disabled has no effect -- the system treats A as disabled. |
| `overridable` | System-critical flags (e.g., `sys.storage-engine`) should be `false` to prevent accidental runtime changes. |
| `requiredRole` | Not enforced today. Reserved for role-based flag filtering when the auth system is implemented. |
---
## Default Flags Configuration
All flags are declared in a single file. This is the source of truth.
```typescript
// src/config/flags.ts
import type { FeatureFlag } from '@/types/flags';
export const defaultFlags: FeatureFlag[] = [
// ─── Module Flags ──────────────────────────────────────────
{
key: 'module.devize-generator',
enabled: true,
label: 'Generator Devize',
description: 'Activeaza modulul de generare devize.',
category: 'module',
overridable: true,
},
{
key: 'module.oferte',
enabled: true,
label: 'Oferte',
description: 'Activeaza modulul de creare si gestionare oferte.',
category: 'module',
overridable: true,
},
{
key: 'module.pontaj',
enabled: true,
label: 'Pontaj',
description: 'Activeaza modulul de pontaj si evidenta ore.',
category: 'module',
overridable: true,
},
// ─── Experimental Flags ────────────────────────────────────
{
key: 'exp.ai-assistant',
enabled: false,
label: 'Asistent AI',
description: 'Activeaza asistentul AI experimental pentru generare de continut.',
category: 'experimental',
overridable: true,
},
{
key: 'exp.dark-mode',
enabled: false,
label: 'Mod Intunecat',
description: 'Activeaza tema intunecata (in testare).',
category: 'experimental',
overridable: true,
},
// ─── System Flags ─────────────────────────────────────────
{
key: 'sys.offline-mode',
enabled: true,
label: 'Mod Offline',
description: 'Permite functionarea aplicatiei fara conexiune la internet.',
category: 'system',
overridable: false,
},
{
key: 'sys.analytics',
enabled: false,
label: 'Analitice',
description: 'Colectare de date de utilizare anonimizate.',
category: 'system',
overridable: false,
},
];
/** Build a lookup map for O(1) access by key */
export const flagDefaults: Map<string, FeatureFlag> = new Map(
defaultFlags.map((flag) => [flag.key, flag])
);
```
---
## FeatureFlagProvider
The provider initializes flag state from defaults, applies environment variable overrides, applies runtime overrides from localStorage, resolves dependencies, and exposes the resolved state via context.
```typescript
// src/providers/FeatureFlagProvider.tsx
'use client';
import React, { createContext, useContext, useMemo, useState, useCallback } from 'react';
import { defaultFlags, flagDefaults } from '@/config/flags';
import type { FeatureFlag } from '@/types/flags';
interface FlagState {
/** Check if a flag is enabled (dependency-resolved) */
isEnabled: (key: string) => boolean;
/** Get the full flag definition */
getFlag: (key: string) => FeatureFlag | undefined;
/** Set a runtime override (only for overridable flags) */
setOverride: (key: string, enabled: boolean) => void;
/** Clear a runtime override */
clearOverride: (key: string) => void;
/** All flags with their resolved states */
allFlags: FeatureFlag[];
}
const FeatureFlagContext = createContext<FlagState | null>(null);
const STORAGE_KEY = 'architools.flag-overrides';
function loadOverrides(): Record<string, boolean> {
if (typeof window === 'undefined') return {};
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}
function saveOverrides(overrides: Record<string, boolean>): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides));
}
function getEnvOverride(key: string): boolean | undefined {
// Convention: NEXT_PUBLIC_FLAG_MODULE_DEVIZE_GENERATOR=true
// Transform flag key: "module.devize-generator" -> "MODULE_DEVIZE_GENERATOR"
const envKey = `NEXT_PUBLIC_FLAG_${key.replace(/[.-]/g, '_').toUpperCase()}`;
const value = process.env[envKey];
if (value === 'true') return true;
if (value === 'false') return false;
return undefined;
}
export function FeatureFlagProvider({ children }: { children: React.ReactNode }) {
const [overrides, setOverrides] = useState<Record<string, boolean>>(loadOverrides);
const resolvedFlags = useMemo(() => {
// Step 1: Compute effective enabled state for each flag
const effective = new Map<string, boolean>();
for (const flag of defaultFlags) {
// Priority: env override > runtime override > compiled default
const envOverride = getEnvOverride(flag.key);
const runtimeOverride = overrides[flag.key];
const baseValue = envOverride ?? runtimeOverride ?? flag.enabled;
effective.set(flag.key, baseValue);
}
// Step 2: Resolve dependencies (a flag is only enabled if all its dependencies are enabled)
const resolved = new Map<string, boolean>();
function resolve(key: string, visited: Set<string> = new Set()): boolean {
if (resolved.has(key)) return resolved.get(key)!;
if (visited.has(key)) return false; // circular dependency -- fail closed
visited.add(key);
const flag = flagDefaults.get(key);
if (!flag) return false;
let enabled = effective.get(key) ?? false;
if (enabled && flag.dependencies?.length) {
enabled = flag.dependencies.every((dep) => resolve(dep, visited));
}
resolved.set(key, enabled);
return enabled;
}
for (const flag of defaultFlags) {
resolve(flag.key);
}
return resolved;
}, [overrides]);
const setOverride = useCallback((key: string, enabled: boolean) => {
const flag = flagDefaults.get(key);
if (!flag?.overridable) {
console.warn(`[FeatureFlags] Flag "${key}" is not overridable.`);
return;
}
setOverrides((prev) => {
const next = { ...prev, [key]: enabled };
saveOverrides(next);
return next;
});
}, []);
const clearOverride = useCallback((key: string) => {
setOverrides((prev) => {
const { [key]: _, ...rest } = prev;
saveOverrides(rest);
return rest;
});
}, []);
const contextValue = useMemo<FlagState>(() => ({
isEnabled: (key: string) => resolvedFlags.get(key) ?? false,
getFlag: (key: string) => flagDefaults.get(key),
setOverride,
clearOverride,
allFlags: defaultFlags.map((f) => ({
...f,
enabled: resolvedFlags.get(f.key) ?? false,
})),
}), [resolvedFlags, setOverride, clearOverride]);
return (
<FeatureFlagContext.Provider value={contextValue}>
{children}
</FeatureFlagContext.Provider>
);
}
export function useFeatureFlagContext(): FlagState {
const context = useContext(FeatureFlagContext);
if (!context) {
throw new Error('useFeatureFlagContext must be used within a FeatureFlagProvider');
}
return context;
}
```
### Provider Placement
The provider wraps the entire application layout:
```typescript
// src/app/layout.tsx
import { FeatureFlagProvider } from '@/providers/FeatureFlagProvider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ro">
<body>
<FeatureFlagProvider>
{children}
</FeatureFlagProvider>
</body>
</html>
);
}
```
---
## `useFeatureFlag` Hook
The primary consumer API. Returns a boolean.
```typescript
// src/hooks/useFeatureFlag.ts
'use client';
import { useFeatureFlagContext } from '@/providers/FeatureFlagProvider';
/**
* Check if a feature flag is enabled.
* Returns false for unknown keys (fail closed).
*/
export function useFeatureFlag(key: string): boolean {
const { isEnabled } = useFeatureFlagContext();
return isEnabled(key);
}
```
### Usage
```typescript
function SomeComponent() {
const aiEnabled = useFeatureFlag('exp.ai-assistant');
return (
<div>
{aiEnabled && <AiSuggestionPanel />}
</div>
);
}
```
---
## `FeatureGate` Component
A declarative alternative to the hook, useful when you want to conditionally render a subtree without introducing a new component.
```typescript
// src/components/FeatureGate.tsx
'use client';
import { useFeatureFlag } from '@/hooks/useFeatureFlag';
interface FeatureGateProps {
flag: string;
children: React.ReactNode;
fallback?: React.ReactNode;
}
/**
* Renders children only if the specified flag is enabled.
* Optionally renders a fallback when disabled.
*/
export function FeatureGate({ flag, children, fallback = null }: FeatureGateProps) {
const enabled = useFeatureFlag(flag);
return <>{enabled ? children : fallback}</>;
}
```
### Usage
```tsx
<FeatureGate flag="exp.dark-mode">
<ThemeToggle />
</FeatureGate>
<FeatureGate flag="module.pontaj" fallback={<ModuleDisabledNotice />}>
<PontajWidget />
</FeatureGate>
```
---
## Flags and Module Loading
The critical integration point: **a disabled flag means the module's JavaScript is never fetched**.
This works because of the layered architecture:
1. **Navigation**: The sidebar queries `useFeatureFlag(module.featureFlag)` for each registered module. Disabled modules are excluded from the nav -- the user has no link to click.
2. **Route guard**: The module's route page checks the flag before rendering the `ModuleLoader`. If the flag is disabled, it renders a 404 or redirect.
```typescript
// src/app/(modules)/devize/page.tsx
import { ModuleLoader } from '@/lib/module-loader';
import { moduleRegistry } from '@/config/modules';
import { useFeatureFlag } from '@/hooks/useFeatureFlag';
import { notFound } from 'next/navigation';
export default function DevizePage() {
const config = moduleRegistry.get('devize-generator')!;
const enabled = useFeatureFlag(config.featureFlag);
if (!enabled) {
notFound();
}
return <ModuleLoader config={config} />;
}
```
3. **Lazy import**: `React.lazy(() => import(...))` only executes the import when the component renders. Since the route guard prevents rendering when the flag is off, the dynamic `import()` never fires, and the browser never requests the chunk.
**Result**: Disabling a module flag removes it from the bundle that the user downloads. There is zero performance cost for disabled modules.
---
## Runtime Override Mechanism
Overridable flags can be toggled at runtime through the admin panel or the browser console. Overrides are stored in `localStorage` under the key `architools.flag-overrides`.
### Admin Panel
The admin panel (planned) will render all overridable flags as toggles, grouped by category. It uses `setOverride` and `clearOverride` from the context.
### Console API (Development)
For development and debugging, flags can be manipulated directly:
```javascript
// In the browser console:
// Enable a flag
localStorage.setItem('architools.flag-overrides',
JSON.stringify({ ...JSON.parse(localStorage.getItem('architools.flag-overrides') || '{}'), 'exp.ai-assistant': true })
);
// Then reload the page.
```
A convenience utility can be exposed on `window` in development builds:
```typescript
// src/lib/dev-tools.ts
if (process.env.NODE_ENV === 'development') {
(window as any).__flags = {
enable: (key: string) => { /* calls setOverride */ },
disable: (key: string) => { /* calls setOverride */ },
reset: (key: string) => { /* calls clearOverride */ },
list: () => { /* logs all flags with their resolved states */ },
};
}
```
---
## Flag Categories
### Module Flags (`module.*`)
One flag per module. Controls whether the module is loaded and visible. Key must match the `featureFlag` field in the module's `ModuleConfig`.
Examples: `module.devize-generator`, `module.oferte`, `module.pontaj`.
### Experimental Flags (`exp.*`)
Control features that are under active development or testing. These may cut across modules or affect platform-level behavior. Expected to eventually become permanent (either promoted to always-on or removed).
Examples: `exp.ai-assistant`, `exp.dark-mode`.
### System Flags (`sys.*`)
Control infrastructure-level capabilities. Typically not overridable. Changing these may require understanding of downstream effects.
Examples: `sys.offline-mode`, `sys.analytics`.
---
## Environment Variable Overrides
For deployment-time control, flags can be overridden via environment variables. This is useful for:
- Enabling experimental features in staging but not production.
- Disabling a broken module without a code change.
- Per-environment flag profiles in Docker Compose.
### Convention
```
NEXT_PUBLIC_FLAG_<NORMALIZED_KEY>=true|false
```
The key is normalized by replacing `.` and `-` with `_` and uppercasing:
| Flag Key | Environment Variable |
|---|---|
| `module.devize-generator` | `NEXT_PUBLIC_FLAG_MODULE_DEVIZE_GENERATOR` |
| `exp.ai-assistant` | `NEXT_PUBLIC_FLAG_EXP_AI_ASSISTANT` |
| `sys.offline-mode` | `NEXT_PUBLIC_FLAG_SYS_OFFLINE_MODE` |
### Priority Order
```
Environment variable > Runtime override (localStorage) > Compiled default
```
Environment variables take the highest priority because they represent a deployment decision that should not be overridden by stale localStorage state.
### Docker Compose Example
```yaml
# docker-compose.staging.yml
services:
web:
environment:
- NEXT_PUBLIC_FLAG_EXP_AI_ASSISTANT=true
- NEXT_PUBLIC_FLAG_SYS_ANALYTICS=true
```
---
## Anti-Patterns
### 1. Direct Module Import (bypassing flags)
```typescript
// WRONG: This imports the module unconditionally -- it will be in the bundle
// regardless of the flag state.
import { DevizeGenerator } from '@/modules/devize-generator';
```
Always go through the flag-gated `ModuleLoader` or use `FeatureGate`/`useFeatureFlag`.
### 2. Checking Flags Outside React
```typescript
// WRONG: This reads the default, not the resolved state with overrides.
import { flagDefaults } from '@/config/flags';
if (flagDefaults.get('module.pontaj')?.enabled) { ... }
```
Always use `useFeatureFlag()` inside a component or `useFeatureFlagContext()` to get the resolved state.
### 3. Hardcoding Flag Values
```typescript
// WRONG: This defeats the purpose of having flags.
const isAiEnabled = process.env.NODE_ENV === 'production' ? false : true;
```
Use the flag system. If you need environment-specific behavior, use the env var override mechanism.
### 4. Flag Key Mismatch
The flag `key` in `flags.ts` must exactly match the `featureFlag` in the module's `config.ts`. A mismatch means the module can never be gated. The registry validation (`validateRegistry`) does not currently check this -- it is enforced by code review and the module development checklist.
### 5. Non-Overridable Flags in the Admin Panel
Do not expose `overridable: false` flags in the admin toggle UI. The provider already guards against this, but the UI should also filter them out.
---
## Future: Remote Flag Service
The current system is local-first by design. When a remote flag service is needed (e.g., for per-user rollout, A/B testing, or centralized control), the integration point is the `FeatureFlagProvider`:
1. On mount, the provider fetches flags from the remote service.
2. Remote values are merged into the resolution chain with a priority between env vars and runtime overrides:
```
Env var > Remote service > Runtime override > Compiled default
```
3. The provider caches remote flags in localStorage for offline resilience.
4. Consumer code (`useFeatureFlag`, `FeatureGate`) requires zero changes.
The `FeatureFlag` interface and provider API are designed to accommodate this without breaking changes.

View File

@@ -0,0 +1,512 @@
# Module System
> ArchiTools internal architecture reference -- module registry, loader, and lifecycle.
---
## Overview
ArchiTools is composed of discrete **modules**, each representing a self-contained functional unit (e.g., *Devize*, *Oferte*, *Pontaj*). Modules are registered in a central registry, gated behind feature flags, lazily loaded at runtime, and isolated from one another. This design provides three guarantees:
1. **Zero bundle cost** for disabled modules -- they are never imported.
2. **Runtime safety** -- a crash in one module cannot propagate to another.
3. **Clear ownership boundaries** -- each module owns its types, storage, and UI.
---
## Module Definition Model
Every module exposes a `config.ts` at its root that exports a `ModuleConfig` object. This is the module's identity card -- it tells the platform everything it needs to know to register, gate, load, and display the module.
### `ModuleConfig` Interface
```typescript
// src/core/module-registry/types.ts
type ModuleCategory = 'operations' | 'generators' | 'management' | 'tools' | 'ai';
type Visibility = 'all' | 'admin' | 'internal';
interface ModuleConfig {
id: string; // unique kebab-case identifier, e.g. "devize-generator"
name: string; // Romanian display name, e.g. "Generator Devize"
description: string; // Romanian description shown in module catalog
icon: string; // Lucide icon name, e.g. "file-spreadsheet"
route: string; // base route path, e.g. "/devize"
category: ModuleCategory; // sidebar grouping
featureFlag: string; // corresponding key in the feature flag system
visibility: Visibility; // audience restriction
version: string; // semver, e.g. "1.0.0"
dependencies?: string[]; // other module IDs this depends on
storageNamespace: string; // isolated localStorage/IndexedDB prefix
navOrder: number; // sidebar sort order within its category
tags?: string[]; // capability tags for search/filtering
}
```
### Field Contracts
| Field | Constraint |
|---|---|
| `id` | Must be unique across all modules. Kebab-case. Used as the key in the registry map. |
| `route` | Must start with `/`. Must not collide with another module's route. |
| `featureFlag` | Must have a matching entry in `src/config/flags.ts`. |
| `storageNamespace` | Must be unique. Convention: `architools.[module-id]`. |
| `navOrder` | Lower numbers appear first. Modules in the same category are sorted by this value. |
| `dependencies` | If specified, the platform will refuse to load this module unless all listed module IDs are also enabled and loaded. |
### Example
```typescript
// src/modules/devize-generator/config.ts
import { ModuleConfig } from '@/core/module-registry/types';
export const registraturaConfig: ModuleConfig = {
id: 'registratura',
name: 'Registratură',
description: 'Registru de corespondență multi-firmă cu urmărire documente.',
icon: 'book-open',
route: '/registratura',
category: 'operations',
featureFlag: 'module.registratura',
visibility: 'all',
version: '1.0.0',
dependencies: [],
storageNamespace: 'architools.registratura',
navOrder: 10,
tags: ['registry', 'documents', 'correspondence'],
};
```
---
## Module Directory Structure
Every module lives under `src/modules/` and follows a strict directory convention:
```
src/modules/[module-name]/
components/ # React components specific to this module
hooks/ # Custom hooks encapsulating business logic
services/ # Data services (CRUD, transformations) using the storage abstraction
types.ts # Module-specific TypeScript types and interfaces
config.ts # ModuleConfig export (the module's identity)
index.ts # Public API barrel export
```
### Rules
- **`components/`** -- Only UI components consumed within this module. If a component is needed by multiple modules, it belongs in `src/shared/components/common/`.
- **`hooks/`** -- Business logic hooks. Must not contain UI rendering. Must not directly call `localStorage` or `IndexedDB` -- use the storage abstraction via services.
- **`services/`** -- All data access goes through services. Services consume the platform's storage abstraction layer (`src/core/storage/`) and must scope all keys under `storageNamespace`.
- **`types.ts`** -- All TypeScript types that are internal to this module. Shared types go in `src/core/*/types.ts`.
- **`config.ts`** -- Exports exactly one `ModuleConfig` object. No side effects.
- **`index.ts`** -- The barrel export. This is the **only** file that other parts of the system (the registry, the loader) may import from. It must export `config` and the lazy-loadable root component.
### Barrel Export Convention
```typescript
// src/modules/registratura/index.ts
export { registraturaConfig as config } from './config';
export { default as RegistraturaModule } from './components/RegistraturaModule';
```
The root component (`RegistraturaModule`) is what gets lazy-loaded and rendered at the module's route.
---
## Module Registry
The central registry lives at `src/config/modules.ts`. It imports every module's config and builds a lookup map.
```typescript
// src/config/modules.ts
import { registraturaConfig } from '@/modules/registratura/config';
import { emailSignatureConfig } from '@/modules/email-signature/config';
import { wordXmlConfig } from '@/modules/word-xml/config';
import { promptGeneratorConfig } from '@/modules/prompt-generator/config';
// ... all module configs
import type { ModuleConfig } from '@/core/module-registry/types';
const moduleConfigs: ModuleConfig[] = [
registraturaConfig,
emailSignatureConfig,
wordXmlConfig,
promptGeneratorConfig,
// ... add new modules here
];
/** Map of module ID -> ModuleConfig for O(1) lookups */
export const moduleRegistry: Map<string, ModuleConfig> = new Map(
moduleConfigs.map((config) => [config.id, config])
);
/** All registered configs, sorted by category then navOrder */
export const allModules: ModuleConfig[] = [...moduleConfigs].sort((a, b) => {
if (a.category !== b.category) return a.category.localeCompare(b.category);
return a.navOrder - b.navOrder;
});
/** Get configs for a specific category */
export function getModulesByCategory(category: ModuleConfig['category']): ModuleConfig[] {
return allModules.filter((m) => m.category === category);
}
/** Validate that all module IDs are unique. Called once at startup in dev mode. */
export function validateRegistry(): void {
const ids = moduleConfigs.map((m) => m.id);
const duplicates = ids.filter((id, i) => ids.indexOf(id) !== i);
if (duplicates.length > 0) {
throw new Error(`Duplicate module IDs detected: ${duplicates.join(', ')}`);
}
const routes = moduleConfigs.map((m) => m.route);
const duplicateRoutes = routes.filter((r, i) => routes.indexOf(r) !== i);
if (duplicateRoutes.length > 0) {
throw new Error(`Duplicate module routes detected: ${duplicateRoutes.join(', ')}`);
}
}
```
The registry is a static, build-time artifact. It does not perform any dynamic discovery -- every module must be explicitly imported. This is intentional: it guarantees that the dependency graph is fully visible to the bundler for tree-shaking and code-splitting.
---
## Module Loader
The loader is responsible for conditionally importing and rendering a module based on its feature flag status.
### Lazy Loading Strategy
Each module's root component is wrapped in `React.lazy()`. The import only fires when the component is actually rendered, which only happens when the feature flag is enabled.
```typescript
// src/core/module-registry/loader.ts
import React, { Suspense } from 'react';
import type { ModuleConfig } from '@/core/module-registry/types';
import { ModuleErrorBoundary } from '@/shared/components/common/module-error-boundary';
import { ModuleSkeleton } from '@/shared/components/common/module-skeleton';
/** Registry of lazy component factories, keyed by module ID */
const lazyComponents: Record<string, React.LazyExoticComponent<React.ComponentType>> = {
'registratura': React.lazy(() => import('@/modules/registratura')),
'email-signature': React.lazy(() => import('@/modules/email-signature')),
'word-xml': React.lazy(() => import('@/modules/word-xml')),
'prompt-generator': React.lazy(() => import('@/modules/prompt-generator')),
// ... add new modules here
};
interface ModuleLoaderProps {
config: ModuleConfig;
}
export function ModuleLoader({ config }: ModuleLoaderProps) {
const LazyComponent = lazyComponents[config.id];
if (!LazyComponent) {
return <ModuleNotFound moduleId={config.id} />;
}
return (
<ModuleErrorBoundary moduleId={config.id} moduleName={config.name}>
<Suspense fallback={<ModuleSkeleton moduleName={config.name} />}>
<LazyComponent />
</Suspense>
</ModuleErrorBoundary>
);
}
```
### Key Mechanics
1. **`React.lazy()`** accepts a function that returns a dynamic `import()`. The import is only executed the first time the component renders.
2. **`Suspense`** shows a skeleton loader while the chunk downloads and parses.
3. **`ModuleErrorBoundary`** catches any rendering error within the module and displays a localized error state without crashing the entire application.
### Why Not Automatic Discovery?
We intentionally avoid filesystem-based auto-discovery (e.g., `import.meta.glob`) because:
- It defeats tree-shaking: the bundler must include every discovered module.
- It hides the dependency graph from code review.
- It makes the build non-deterministic if modules are added/removed from the filesystem.
Explicit registration is a small amount of boilerplate that pays for itself in predictability.
---
## Navigation Auto-Discovery
The sidebar reads directly from the module registry to build its navigation tree. No separate nav configuration file exists.
```typescript
// src/components/navigation/Sidebar.tsx (simplified)
import { allModules } from '@/config/modules';
import { useFeatureFlag } from '@/core/feature-flags';
import { groupBy } from '@/shared/lib/utils';
const CATEGORY_LABELS: Record<string, string> = {
operations: 'Operatiuni',
generators: 'Generatoare',
management: 'Management',
tools: 'Instrumente',
ai: 'AI & Automatizari',
};
export function Sidebar() {
const grouped = groupBy(allModules, (m) => m.category);
return (
<nav>
{Object.entries(CATEGORY_LABELS).map(([category, label]) => {
const modules = grouped[category] ?? [];
const visibleModules = modules.filter((m) => {
const flagEnabled = useFeatureFlag(m.featureFlag);
return flagEnabled;
});
if (visibleModules.length === 0) return null;
return (
<SidebarGroup key={category} label={label}>
{visibleModules.map((m) => (
<SidebarItem key={m.id} icon={m.icon} href={m.route}>
{m.name}
</SidebarItem>
))}
</SidebarGroup>
);
})}
</nav>
);
}
```
This means:
- Adding a module to the registry and enabling its flag automatically adds it to the sidebar.
- Disabling a flag automatically removes the module from navigation.
- `navOrder` controls position within each category group.
- `visibility` can be checked against the current user's role for further filtering.
---
## Module Isolation Rules
Modules must be isolated from one another. This is enforced by convention and code review.
### What Is Allowed
| From | To | Allowed? |
|---|---|---|
| Module A component | Module A hook | Yes |
| Module A service | `src/lib/storage/` | Yes |
| Module A component | `src/components/shared/` | Yes |
| Module A hook | `src/lib/*` (platform utilities) | Yes |
| Module A | Module B (any file) | **No** |
### What Is Not Allowed
- **Direct imports between modules.** Module A must never `import { something } from '@/modules/module-b/...'`. If two modules need to share data, they do so through a shared service in `src/lib/` or through an event bus.
- **Shared mutable state.** No module may write to a global store that another module reads, unless that store is a platform-level shared service (e.g., user context, notification system).
- **Cross-module storage access.** Each module's `storageNamespace` is its own territory. Module A must never read or write keys prefixed with Module B's namespace.
### Enforcement
- **ESLint rule** (planned): a custom `no-restricted-imports` pattern that forbids `@/modules/*/` imports from within a different module directory.
- **Code review**: any PR that introduces a cross-module import must be flagged.
---
## Module Lifecycle
A module goes through the following stages from registration to active use:
```
1. Registration
Module config is imported into src/config/modules.ts.
Module appears in the registry map.
2. Feature Flag Check
At render time, the navigation system and route handler check
the module's featureFlag via useFeatureFlag(). If disabled,
the module is invisible and its code is never loaded.
3. Lazy Load
When the user navigates to the module's route (and the flag is
enabled), React.lazy() triggers the dynamic import(). The
bundler fetches the module's code chunk over the network.
4. Suspense Boundary
While the chunk loads, the Suspense fallback (ModuleSkeleton)
is displayed.
5. Error Boundary Setup
The ModuleErrorBoundary wraps the module. If any error occurs
during mount or subsequent renders, the boundary catches it.
6. Mount
The module's root component mounts. It may initialize hooks,
call services, and read from its storage namespace.
7. Storage Namespace Init
On first use, the module's services initialize their storage
keys under the module's storageNamespace prefix. This happens
lazily -- no storage is allocated until the module actually
writes data.
8. Active
The module is fully interactive. It manages its own state
and communicates with the platform only through shared services.
9. Unmount
When the user navigates away, the module unmounts normally via
React's unmount lifecycle. Cleanup happens in useEffect
return functions as usual.
```
### Diagram
```
Registration ──> Flag Check ──┬──> [disabled] ──> Not loaded, not visible
└──> [enabled] ──> Lazy Load ──> Suspense ──> Mount ──> Active
└──> [error] ──> Error Boundary UI
```
---
## Error Boundaries
Every module is wrapped in a `ModuleErrorBoundary`. This is a React error boundary class component that:
1. Catches JavaScript errors in the module's component tree.
2. Logs the error with the module's ID for diagnostics.
3. Renders a fallback UI with the module name and a retry button.
4. Does **not** crash the shell (sidebar, header, other modules remain functional).
```typescript
// src/components/ModuleErrorBoundary.tsx
import React from 'react';
interface Props {
moduleId: string;
moduleName: string;
children: React.ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ModuleErrorBoundary extends React.Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error(
`[ModuleError] ${this.props.moduleId}:`,
error,
errorInfo.componentStack
);
// Future: send to error reporting service
}
handleRetry = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<h2 className="text-lg font-semibold text-destructive">
Eroare in modulul &ldquo;{this.props.moduleName}&rdquo;
</h2>
<p className="mt-2 text-sm text-muted-foreground">
A aparut o eroare neasteptata. Incercati din nou sau contactati echipa tehnica.
</p>
<button
onClick={this.handleRetry}
className="mt-4 rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground"
>
Reincearca
</button>
</div>
);
}
return this.props.children;
}
}
```
---
## How to Add a New Module
Step-by-step checklist for adding a module to ArchiTools. For full code examples, see [MODULE-DEVELOPMENT.md](/docs/guides/MODULE-DEVELOPMENT.md).
### 1. Create the directory structure
```bash
mkdir -p src/modules/[module-name]/{components,hooks,services}
touch src/modules/[module-name]/{types.ts,config.ts,index.ts}
```
### 2. Write `config.ts`
Export a `ModuleConfig` with a unique `id`, `route`, `featureFlag`, and `storageNamespace`.
### 3. Implement the module
Build types, services, hooks, and components inside the module directory.
### 4. Create `index.ts` barrel export
Export `config` and the default root component.
### 5. Register in `src/config/modules.ts`
Import the config and add it to the `moduleConfigs` array.
### 6. Register the lazy component in `src/lib/module-loader.ts`
Add a `React.lazy(() => import(...))` entry keyed by the module ID.
### 7. Add a feature flag in `src/config/flags.ts`
Create a flag with the key matching the module's `featureFlag` field.
### 8. Create the route
Add `src/app/(modules)/[module-name]/page.tsx` that renders the `ModuleLoader`.
### 9. Test
- Verify the module appears in the sidebar when the flag is enabled.
- Verify the module is completely absent (no network requests for its chunk) when the flag is disabled.
- Verify the error boundary catches a thrown error gracefully.
- Verify storage operations use the correct namespace prefix.
---
## Appendix: Module Category Descriptions
| Category | Key | Intended Use |
|---|---|---|
| Operațiuni | `operations` | Day-to-day operational tools (registratura, password vault) |
| Generatoare | `generators` | Document/artifact generators (email signature, Word XML, Word templates) |
| Management | `management` | Registries and asset management (IT inventory, address book, digital signatures) |
| Instrumente | `tools` | Utility tools (mini utilities, tag manager) |
| AI & Automatizări | `ai` | AI-powered features (prompt generator, AI chat) |

View File

@@ -0,0 +1,430 @@
# Security & Roles
> ArchiTools internal architecture reference -- security layers, role model, and auth integration plan.
---
## Overview
ArchiTools is an internal-only platform deployed on-premise behind Nginx Proxy Manager. It is not exposed to the public internet. The current security posture reflects this: no authentication is required, and all features are available to anyone with network access.
This document defines the layered security model that is designed today and will be progressively enforced as the platform matures. The architecture is **future-ready** -- every entity, module, and field already carries the metadata needed for access control, even before authentication is turned on.
---
## Security Layers
Security is enforced at five distinct layers, from outermost to innermost:
```
┌─────────────────────────────────────────────┐
│ 1. Network Layer │
│ Nginx Proxy Manager, CrowdSec, VPN │
├─────────────────────────────────────────────┤
│ 2. Application Layer │
│ Authentik SSO, session management │
├─────────────────────────────────────────────┤
│ 3. Module Layer │
│ Feature flags, role-based module gating │
├─────────────────────────────────────────────┤
│ 4. Data Layer │
│ Visibility field on all entities │
├─────────────────────────────────────────────┤
│ 5. Field Layer │
│ Admin-only fields on entities │
└─────────────────────────────────────────────┘
```
### Layer 1: Network
| Component | Purpose | Status |
|---|---|---|
| Nginx Proxy Manager | Reverse proxy, TLS termination, access control lists | Active |
| CrowdSec | Intrusion detection, IP reputation, automated banning | Planned |
| Internal DNS | ArchiTools resolves only on the office network | Active |
| VPN | Remote access for authorized employees only | Active (WireGuard) |
ArchiTools is **never** exposed on a public IP. All traffic enters through Nginx Proxy Manager on the Docker host. CrowdSec will be added as a sidecar container to provide real-time threat detection and community-sourced IP blocklists.
### Layer 2: Application (Authentik SSO)
Not yet active. When enabled, Authentik will serve as the single sign-on provider for all ArchiTools users. See [Auth Integration Plan](#auth-integration-plan) below.
### Layer 3: Module
Every module has a `featureFlag` and a `visibility` field in its `ModuleConfig`. Today, feature flags control whether a module is loaded at all. When auth is active, the visibility field will additionally gate access based on the user's role.
```typescript
// Current enforcement (no auth):
// featureFlag=true -> module is loaded
// featureFlag=false -> module is not loaded, chunk is not fetched
// Future enforcement (with auth):
// featureFlag=true AND role meets visibility -> module is loaded
// Otherwise -> module is hidden from navigation and routes return 403
```
### Layer 4: Data
Every persistent entity in ArchiTools carries a `visibility` field:
```typescript
type Visibility = 'all' | 'internal' | 'admin' | 'guest-safe';
```
| Value | Who can see it |
|---|---|
| `all` | Any authenticated user (or everyone, in no-auth mode) |
| `internal` | Users with role `user` or above (not `guest`) |
| `admin` | Users with role `admin` only |
| `guest-safe` | Explicitly marked as safe for external/guest viewers |
This field is stored with the entity data and checked at query time. Services must filter results by visibility before returning them to the UI.
### Layer 5: Field
Individual fields on entities can be marked as admin-only. This is enforced at the component level -- admin-only fields are not rendered (not merely hidden with CSS) for non-admin users.
```typescript
interface FieldConfig {
key: string;
label: string;
adminOnly?: boolean; // field is excluded from render for non-admins
visibility?: Visibility; // field-level visibility override
}
```
When auth is inactive, all fields are rendered (the stub returns admin role).
---
## Role Model
Roles are designed now and embedded into interfaces and type definitions so that the codebase is ready for auth without structural changes.
```typescript
type Role = 'admin' | 'manager' | 'user' | 'viewer' | 'guest';
```
### Role Definitions
| Role | Module Access | Data Scope | Write Access | Configuration |
|---|---|---|---|---|
| `admin` | All modules | All data, all companies | Full CRUD | Full (flags, settings, users) |
| `manager` | All internal modules | Company-scoped: all data within assigned companies | Full CRUD within scope | Module-level settings |
| `user` | Standard modules | Own data + shared data | CRUD on own data | Personal preferences only |
| `viewer` | Permitted modules (read-only) | Shared data visible to their role | None | None |
| `guest` | Public-facing views only | `guest-safe` entities only | None | None |
### Role Hierarchy
Roles form a strict hierarchy. A higher role inherits all permissions of lower roles:
```
admin > manager > user > viewer > guest
```
The `hasRole` check uses this hierarchy:
```typescript
const ROLE_HIERARCHY: Record<Role, number> = {
guest: 0,
viewer: 1,
user: 2,
manager: 3,
admin: 4,
};
function hasRole(requiredRole: Role, actualRole: Role): boolean {
return ROLE_HIERARCHY[actualRole] >= ROLE_HIERARCHY[requiredRole];
}
```
### Company Scoping
The three companies (Beletage SRL, Urban Switch SRL, Studii de Teren SRL) are first-class entities. Managers are assigned to one or more companies and can only manage data within their scope.
```typescript
type CompanyId = 'beletage' | 'urban-switch' | 'studii-de-teren';
interface UserProfile {
id: string;
name: string;
email: string;
role: Role;
companies: CompanyId[]; // which companies this user belongs to
primaryCompany: CompanyId;
}
```
Admins are implicitly scoped to all companies. Users and viewers see data for the companies they belong to.
---
## Visibility Model
```typescript
type Visibility = 'all' | 'internal' | 'admin' | 'guest-safe';
```
Visibility is applied at three levels:
| Level | Where it lives | What it controls |
|---|---|---|
| Module | `ModuleConfig.visibility` | Whether the module appears in navigation and is routable |
| Entity | Entity data (e.g., `registryEntry.visibility`) | Whether the entity appears in query results |
| Field | `FieldConfig.visibility` or `FieldConfig.adminOnly` | Whether the field is rendered in the UI |
### Resolution Logic
```typescript
function canView(userRole: Role, visibility: Visibility): boolean {
switch (visibility) {
case 'all':
return true;
case 'internal':
return hasRole('user', userRole);
case 'admin':
return hasRole('admin', userRole);
case 'guest-safe':
return true; // visible to everyone including guests
}
}
```
---
## Auth Integration Plan
### Phase 1: No Auth (Current)
- All features are available to any user on the network.
- The `AuthContext` stub returns a synthetic admin user.
- Feature flags are the only access control mechanism.
- Visibility fields are stored on entities but not enforced at query time.
### Phase 2: Authentik SSO + Module Gating
- Authentik is deployed as a Docker container alongside ArchiTools.
- OIDC integration: ArchiTools redirects unauthenticated requests to Authentik.
- Authentik manages user accounts, passwords, and MFA.
- Role is assigned in Authentik as a custom claim and mapped to the ArchiTools `Role` type.
- Module-level gating is enforced: users only see modules permitted by their role + module visibility.
- The `AuthContext` reads from the Authentik session instead of the stub.
### Phase 3: Data-Level Permissions
- Entity visibility is enforced at query time in services.
- Company scoping is active: managers see only their companies' data.
- Field-level visibility is enforced: admin-only fields are excluded from non-admin renders.
- Audit logging: who accessed what, when.
### Phase 4: External / Guest Access
- Guest role is activated for external collaborators.
- Scoped views: guests see only `guest-safe` entities, through dedicated guest routes.
- Link-based sharing: generate time-limited URLs for specific records.
- No direct database access -- guest views are read-only projections.
---
## AuthContext Design
The `AuthContext` is the single source of truth for the current user's identity and permissions throughout the React component tree.
```typescript
// src/lib/auth/types.ts
interface AuthContext {
user: UserProfile | null;
role: Role;
isAuthenticated: boolean;
hasRole(role: Role): boolean;
canAccess(moduleId: string): boolean;
canView(visibility: Visibility): boolean;
}
```
### Stub Implementation (Phase 1)
```typescript
// src/lib/auth/auth-context.ts
import { createContext, useContext } from 'react';
import type { AuthContext as AuthContextType } from './types';
const STUB_USER: UserProfile = {
id: 'dev-admin',
name: 'Developer',
email: 'dev@architools.local',
role: 'admin',
companies: ['beletage', 'urban-switch', 'studii-de-teren'],
primaryCompany: 'beletage',
};
const stubContext: AuthContextType = {
user: STUB_USER,
role: 'admin',
isAuthenticated: true,
hasRole: () => true,
canAccess: () => true,
canView: () => true,
};
const AuthContext = createContext<AuthContextType>(stubContext);
export function useAuth(): AuthContextType {
return useContext(AuthContext);
}
export { AuthContext };
```
In development mode, the stub grants full admin access. When Authentik is integrated (Phase 2), the provider will be swapped to read from the OIDC session, and the stub will only be used in test environments.
### Usage in Components
```typescript
// Gating a module route
function ModulePage({ config }: { config: ModuleConfig }) {
const auth = useAuth();
if (!auth.canAccess(config.id)) {
return <AccessDenied />;
}
return <ModuleLoader config={config} />;
}
// Gating a field
function EntityDetail({ entity }: { entity: RegistryEntry }) {
const auth = useAuth();
return (
<div>
<p>{entity.title}</p>
{auth.hasRole('admin') && (
<p className="text-sm text-muted-foreground">
Internal notes: {entity.adminNotes}
</p>
)}
</div>
);
}
```
---
## Module-Specific Security Notes
### Password Vault
The Password Vault module is a **convenience tool for non-critical credentials only**. It is explicitly **not** a production secrets manager.
**Constraints:**
- Passwords are stored in the browser's localStorage, encrypted with a user-provided passphrase using AES-256-GCM via the Web Crypto API.
- The encryption key is derived from the passphrase using PBKDF2 with a per-vault salt.
- There is **no server-side key management**, no HSM, and no key escrow.
- If the user forgets the passphrase, the vault data is unrecoverable.
- This module must display a persistent disclaimer banner:
> **Atentie:** Acest modul este destinat exclusiv pentru credentiale non-critice (conturi de servicii, parole Wi-Fi, etc.). NU stocati parole bancare, chei SSH private, sau secrete de productie. Folositi un manager de parole dedicat (Bitwarden, 1Password) pentru credentiale critice.
**What must NOT be stored here:**
- Banking credentials
- SSH private keys
- API keys for production services
- Personal identity documents
### Digital Signatures
- The module stores **file hashes** (SHA-256), not raw private keys.
- Signature verification is hash-based: the document is re-hashed and compared to the stored hash + signer metadata.
- No cryptographic signing keys are generated or stored in ArchiTools.
- The signature record structure:
```typescript
interface SignatureRecord {
id: string;
documentHash: string; // SHA-256 hex digest
fileName: string;
signerName: string;
signerCompany: CompanyId;
signedAt: string; // ISO 8601 timestamp
metadata?: Record<string, string>;
}
```
### Data Export / Import
- All import operations must sanitize incoming data before writing to storage.
- JSON imports: validate against the expected schema using Zod. Reject payloads that do not conform.
- CSV imports: escape all string fields, reject fields exceeding length limits.
- No `eval()`, no `Function()` constructors, no dynamic code execution on imported data.
- Export operations must strip admin-only fields when the exporter's role is below `admin`.
---
## Web Security Controls
### XSS Prevention
React escapes all interpolated values by default. This is the primary XSS defense.
**Rules:**
- `dangerouslySetInnerHTML` is **prohibited** except in the Email Signature preview and Word XML preview modules. These modules render user-provided HTML in a sandboxed `<iframe>` with `sandbox="allow-same-origin"` (no `allow-scripts`).
- User input that flows into HTML attributes (e.g., `href`, `src`) must be validated against an allowlist of protocols (`https:`, `mailto:`, `tel:`).
- No inline `<script>` tags are generated anywhere in the application.
### CSRF Protection
Next.js App Router provides built-in CSRF protection for Server Actions via origin checking. Since ArchiTools currently uses client-side data storage (localStorage/IndexedDB) rather than a backend API, CSRF is not an active vector. When server-side endpoints are added in future phases, all mutations must use Server Actions or include a CSRF token.
### Content Security Policy
The following CSP headers are configured in the Nginx Proxy Manager for the ArchiTools domain:
```
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';
connect-src 'self';
frame-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
```
**Notes:**
- `unsafe-inline` and `unsafe-eval` in `script-src` are required by Next.js in development. In production, these should be tightened to use nonce-based CSP once the build pipeline supports it.
- `object-src 'none'` blocks Flash and other plugin-based content.
- `frame-src 'self'` allows the sandboxed iframes used by the Email Signature and Word XML preview modules.
### Security Headers (Nginx)
In addition to CSP, the following headers are set at the Nginx level:
```nginx
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "0" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
```
`X-XSS-Protection` is set to `0` (disabled) because the modern approach is to rely on CSP rather than the browser's legacy XSS auditor, which can introduce vulnerabilities of its own.
---
## Summary
| Layer | Current State | Phase 2 | Phase 3 | Phase 4 |
|---|---|---|---|---|
| Network | Nginx + internal DNS | + CrowdSec | No change | + guest ingress rules |
| Application | No auth (stub) | Authentik SSO | No change | + guest OIDC flow |
| Module | Feature flags only | + role-based gating | No change | + guest module set |
| Data | Visibility stored, not enforced | No change | Enforced at query time | + guest-safe filter |
| Field | All fields rendered | No change | Admin-only fields gated | No change |

View File

@@ -0,0 +1,691 @@
# Storage Layer Architecture
> Internal reference for the ArchiTools storage abstraction layer.
---
## Overview
ArchiTools uses an adapter-based storage abstraction that decouples all module data operations from the underlying persistence mechanism. The default adapter uses `localStorage` for zero-infrastructure demo/development mode. The architecture supports swapping to REST API, MinIO, or database backends without changing module code.
**Key principle:** No module may import `localStorage`, `fetch` to a storage endpoint, or any persistence API directly. All data access flows through the `StorageService` interface via the `useStorage` hook.
---
## StorageService Interface
```typescript
// src/lib/storage/types.ts
interface StorageService {
/** Retrieve a single item by namespace and key. Returns null if not found. */
get<T>(namespace: string, key: string): Promise<T | null>;
/** Persist a value under namespace/key. Overwrites existing value. */
set<T>(namespace: string, key: string, value: T): Promise<void>;
/** Remove a single item. No-op if key does not exist. */
delete(namespace: string, key: string): Promise<void>;
/** List all keys within a namespace. Returns empty array if namespace is empty. */
list(namespace: string): Promise<string[]>;
/** Query all items in a namespace, filtering by predicate. */
query<T>(namespace: string, predicate: (item: T) => boolean): Promise<T[]>;
/** Remove all data within a namespace. */
clear(namespace: string): Promise<void>;
/** Export entire namespace as a key-value record. Used for migration and backup. */
export(namespace: string): Promise<Record<string, unknown>>;
/** Import a key-value record into a namespace. Merges with existing data by default. */
import(namespace: string, data: Record<string, unknown>): Promise<void>;
}
```
All methods are async. Even `LocalStorageAdapter` returns promises to maintain interface compatibility and allow drop-in replacement with async backends.
---
## Adapter Pattern
### Architecture
```
Module code
useStorage(namespace) hook
StorageProvider context
StorageService interface
┌─────────────────────────────────┐
│ LocalStorageAdapter (default) │
│ ApiStorageAdapter (future) │
│ MinioAdapter (future) │
└─────────────────────────────────┘
```
### Adapter Implementations
#### `LocalStorageAdapter`
**File:** `src/lib/storage/adapters/local-storage.ts`
- Default adapter, used in demo mode and local development.
- Client-only. Must not be invoked during SSR; all calls should be guarded or deferred to `useEffect`.
- Storage key format: `architools::{namespace}::{key}`
- Values are JSON-serialized via `JSON.stringify` / `JSON.parse`.
- Enforces per-namespace size quota (default: 2 MB per namespace, configurable).
- Total localStorage budget: 5 MB (browser limit). The adapter tracks usage and throws `StorageQuotaExceededError` when limits are hit.
```typescript
// Key format
function storageKey(namespace: string, key: string): string {
return `architools::${namespace}::${key}`;
}
```
**Size tracking:**
```typescript
// Approximate size calculation for quota enforcement
function byteSize(value: string): number {
return new Blob([value]).size;
}
```
#### `ApiStorageAdapter` (Future)
**File:** `src/lib/storage/adapters/api-storage.ts`
- Communicates with a REST API backend (`STORAGE_API_URL`).
- Endpoint mapping:
- `GET /storage/{namespace}/{key}` — get
- `PUT /storage/{namespace}/{key}` — set
- `DELETE /storage/{namespace}/{key}` — delete
- `GET /storage/{namespace}` — list
- `POST /storage/{namespace}/query` — query (sends predicate as filter object, not function)
- `DELETE /storage/{namespace}` — clear
- `GET /storage/{namespace}/export` — export
- `POST /storage/{namespace}/import` — import
- Authentication: Bearer token from Authentik session (when auth is enabled).
- Query method: The API adapter cannot serialize JavaScript predicates. Instead, `query` fetches all items and filters client-side, or accepts a structured filter object for server-side filtering.
#### `MinioAdapter` (Future)
**File:** `src/lib/storage/adapters/minio-storage.ts`
- For file/object storage (signatures, stamps, templates, uploaded documents).
- Not a general key-value store; used alongside `ApiStorageAdapter` for binary assets.
- Object path format: `{bucket}/{namespace}/{key}`
- Presigned URLs for browser uploads/downloads.
- Server-side only (access keys must not be exposed to client).
---
## Namespace Isolation
Every module operates within its own namespace. Namespaces are string identifiers that partition data to prevent collisions.
### Assigned Namespaces
| Module | Namespace |
|---------------------|-----------------------|
| Dashboard | `dashboard` |
| Registratura | `registratura` |
| Email Signature | `email-signature` |
| Word XML Generators | `word-xml` |
| Digital Signatures | `digital-signatures` |
| Password Vault | `password-vault` |
| IT Inventory | `it-inventory` |
| Address Book | `address-book` |
| Prompt Generator | `prompt-generator` |
| Word Templates | `word-templates` |
| Tag Manager | `tag-manager` |
| Mini Utilities | `mini-utilities` |
| AI Chat | `ai-chat` |
| System/Settings | `system` |
### Namespace Rules
1. Namespaces are lowercase kebab-case strings.
2. A module must never read or write to another module's namespace directly.
3. Cross-module data access happens through shared services (e.g., the tag service reads from `tag-manager` namespace on behalf of any module).
4. The `system` namespace is reserved for platform-level configuration (theme preference, sidebar state, feature flag overrides).
---
## StorageProvider and useStorage Hook
### StorageProvider
```typescript
// src/lib/storage/provider.tsx
import { createContext, useContext, useMemo } from 'react';
interface StorageContextValue {
service: StorageService;
}
const StorageContext = createContext<StorageContextValue | null>(null);
interface StorageProviderProps {
adapter?: StorageService;
children: React.ReactNode;
}
export function StorageProvider({ adapter, children }: StorageProviderProps) {
const service = useMemo(() => {
return adapter ?? createDefaultAdapter();
}, [adapter]);
return (
<StorageContext.Provider value={{ service }}>
{children}
</StorageContext.Provider>
);
}
```
The `StorageProvider` wraps the application in `src/app/layout.tsx`. Adapter selection is determined at startup based on the `NEXT_PUBLIC_STORAGE_ADAPTER` environment variable.
### Adapter Factory
```typescript
// src/lib/storage/factory.ts
export function createDefaultAdapter(): StorageService {
const adapterType = process.env.NEXT_PUBLIC_STORAGE_ADAPTER ?? 'localStorage';
switch (adapterType) {
case 'localStorage':
return new LocalStorageAdapter();
case 'api':
return new ApiStorageAdapter(process.env.STORAGE_API_URL!);
default:
throw new Error(`Unknown storage adapter: ${adapterType}`);
}
}
```
### `useStorage` Hook
```typescript
// src/lib/storage/hooks.ts
export function useStorage<T = unknown>(namespace: string) {
const context = useContext(StorageContext);
if (!context) {
throw new Error('useStorage must be used within StorageProvider');
}
const { service } = context;
return useMemo(() => ({
get: (key: string) => service.get<T>(namespace, key),
set: (key: string, value: T) => service.set<T>(namespace, key, value),
delete: (key: string) => service.delete(namespace, key),
list: () => service.list(namespace),
query: (predicate: (item: T) => boolean) => service.query<T>(namespace, predicate),
clear: () => service.clear(namespace),
export: () => service.export(namespace),
import: (data: Record<string, unknown>) => service.import(namespace, data),
}), [service, namespace]);
}
```
**Usage in a module:**
```typescript
function RegistraturaPage() {
const storage = useStorage<RegistryEntry>('registratura');
async function loadEntries() {
const keys = await storage.list();
// ...
}
async function saveEntry(entry: RegistryEntry) {
await storage.set(entry.id, entry);
}
}
```
---
## Data Serialization
### Rules
1. All structured data is serialized as JSON via `JSON.stringify`.
2. Dates are stored as ISO 8601 strings (`"2025-03-15T10:30:00.000Z"`), never as `Date` objects.
3. Binary data (images, files) is not stored in the key-value layer. Use MinIO or base64-encoded strings (for small assets under 100 KB only, e.g., signature images in `digital-signatures`).
4. `undefined` values are stripped during serialization. Use `null` for intentional absence.
5. Keys must be valid URL path segments: lowercase alphanumeric, hyphens, and underscores only.
### Validation
```typescript
const KEY_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
function validateKey(key: string): void {
if (!KEY_PATTERN.test(key)) {
throw new StorageKeyError(`Invalid storage key: "${key}"`);
}
}
```
---
## Migration Strategy
### localStorage to API Backend
The export/import methods on `StorageService` enable namespace-level data migration.
**Migration flow:**
```
1. User triggers export from admin panel
2. For each namespace:
a. Call oldAdapter.export(namespace) → JSON record
b. Download as backup file (optional)
c. Call newAdapter.import(namespace, data)
d. Verify item count matches
3. Switch NEXT_PUBLIC_STORAGE_ADAPTER to 'api'
4. Restart application
```
**Migration script (admin utility):**
```typescript
async function migrateStorage(
source: StorageService,
target: StorageService,
namespaces: string[]
): Promise<MigrationReport> {
const report: MigrationReport = { namespaces: [], errors: [] };
for (const ns of namespaces) {
try {
const data = await source.export(ns);
const keyCount = Object.keys(data).length;
await target.import(ns, data);
const verifyKeys = await target.list(ns);
report.namespaces.push({
namespace: ns,
exported: keyCount,
imported: verifyKeys.length,
success: keyCount === verifyKeys.length,
});
} catch (error) {
report.errors.push({ namespace: ns, error: String(error) });
}
}
return report;
}
```
### Schema Versioning
Each namespace should store a `_meta` key containing schema version:
```typescript
interface NamespaceMeta {
schemaVersion: number;
lastMigration: string; // ISO 8601
itemCount: number;
}
```
On application startup, the storage layer checks `_meta.schemaVersion` and runs registered migration functions if the version is behind.
---
## Configuration
### Environment Variables
| Variable | Default | Description |
|---|---|---|
| `NEXT_PUBLIC_STORAGE_ADAPTER` | `localStorage` | Active adapter: `localStorage`, `api` |
| `STORAGE_API_URL` | — | REST API endpoint (for `api` adapter) |
| `MINIO_ENDPOINT` | — | MinIO server address |
| `MINIO_ACCESS_KEY` | — | MinIO access key (server-side only) |
| `MINIO_SECRET_KEY` | — | MinIO secret key (server-side only) |
| `MINIO_BUCKET` | `architools` | MinIO bucket name |
Variables prefixed with `NEXT_PUBLIC_` are available on the client. MinIO credentials are server-side only and must never be exposed to the browser.
---
## Size Limits and Quotas
### localStorage Adapter
| Limit | Value | Notes |
|---|---|---|
| Browser total | ~5 MB | Varies by browser. Chrome/Firefox: 5 MB per origin. |
| Per-namespace soft limit | 2 MB | Configurable in adapter constructor. |
| Per-value max size | 1 MB | Prevents accidental storage of large blobs. |
| Key length max | 128 characters | Including namespace prefix. |
When a quota is exceeded, the adapter throws `StorageQuotaExceededError`. Module code must handle this gracefully — typically by showing a user-facing error and suggesting data export/cleanup.
### Quota Enforcement
```typescript
class LocalStorageAdapter implements StorageService {
private namespaceQuota: number; // bytes, default 2 * 1024 * 1024
async set<T>(namespace: string, key: string, value: T): Promise<void> {
const serialized = JSON.stringify(value);
const size = byteSize(serialized);
if (size > this.maxValueSize) {
throw new StorageQuotaExceededError(
`Value exceeds max size: ${size} bytes (limit: ${this.maxValueSize})`
);
}
const currentUsage = await this.getNamespaceSize(namespace);
if (currentUsage + size > this.namespaceQuota) {
throw new StorageQuotaExceededError(
`Namespace "${namespace}" quota exceeded: ${currentUsage + size} bytes (limit: ${this.namespaceQuota})`
);
}
localStorage.setItem(storageKey(namespace, key), serialized);
}
}
```
---
## Error Handling
### Error Types
```typescript
// src/lib/storage/errors.ts
export class StorageError extends Error {
constructor(message: string, public readonly code: string) {
super(message);
this.name = 'StorageError';
}
}
export class StorageKeyError extends StorageError {
constructor(message: string) {
super(message, 'INVALID_KEY');
}
}
export class StorageQuotaExceededError extends StorageError {
constructor(message: string) {
super(message, 'QUOTA_EXCEEDED');
}
}
export class StorageNotFoundError extends StorageError {
constructor(namespace: string, key: string) {
super(`Key "${key}" not found in namespace "${namespace}"`, 'NOT_FOUND');
}
}
export class StorageNetworkError extends StorageError {
constructor(message: string) {
super(message, 'NETWORK_ERROR');
}
}
export class StorageSerializationError extends StorageError {
constructor(message: string) {
super(message, 'SERIALIZATION_ERROR');
}
}
```
### Error Handling Patterns
**In adapters:** Adapters catch raw errors (JSON parse failures, network timeouts, `QuotaExceededError` from the browser) and re-throw as typed `StorageError` subclasses.
**In hooks/modules:** Use try/catch with specific error type checks:
```typescript
try {
await storage.set(entry.id, entry);
} catch (error) {
if (error instanceof StorageQuotaExceededError) {
toast.error('Spațiul de stocare este plin. Exportați datele vechi.');
} else if (error instanceof StorageNetworkError) {
toast.error('Eroare de conexiune. Încercați din nou.');
} else {
toast.error('Eroare la salvare.');
console.error('[Storage]', error);
}
}
```
---
## Update Patterns
### Optimistic Updates
Used for fast UI response when data loss risk is low (e.g., updating a tag, toggling a flag).
```typescript
function useOptimisticUpdate<T extends BaseEntity>(namespace: string) {
const storage = useStorage<T>(namespace);
const [items, setItems] = useState<T[]>([]);
async function update(id: string, patch: Partial<T>) {
// 1. Apply optimistically to local state
const previous = [...items];
setItems(items.map(item =>
item.id === id ? { ...item, ...patch, updatedAt: new Date().toISOString() } : item
));
try {
// 2. Persist to storage
const current = await storage.get(id);
if (current) {
await storage.set(id, { ...current, ...patch, updatedAt: new Date().toISOString() });
}
} catch (error) {
// 3. Rollback on failure
setItems(previous);
throw error;
}
}
return { items, setItems, update };
}
```
### Pessimistic Updates
Used when data integrity is critical (e.g., registry entries, password vault). The UI waits for confirmation before updating local state.
```typescript
async function saveEntry(entry: RegistryEntry) {
setLoading(true);
try {
await storage.set(entry.id, entry);
// Only update UI after successful persistence
setEntries(prev => [...prev, entry]);
} catch (error) {
// UI remains unchanged; show error
handleStorageError(error);
} finally {
setLoading(false);
}
}
```
### Guidelines
| Scenario | Pattern | Reason |
|---|---|---|
| Tag edits, dashboard widget reorder | Optimistic | Low risk, frequent interaction |
| Registry entry creation | Pessimistic | Data integrity matters |
| Password vault writes | Pessimistic | Security-sensitive |
| Bulk import | Pessimistic with progress | Large operation, needs feedback |
| Settings/preferences | Optimistic | Trivial to re-apply |
---
## Caching Strategy
### localStorage Adapter
No caching layer needed. `localStorage` is synchronous and in-memory in the browser. The async wrapper adds negligible overhead.
### API Adapter (Future)
The API adapter should implement a write-through cache:
```typescript
class ApiStorageAdapter implements StorageService {
private cache: Map<string, { value: unknown; timestamp: number }> = new Map();
private cacheTTL = 30_000; // 30 seconds
async get<T>(namespace: string, key: string): Promise<T | null> {
const cacheKey = `${namespace}::${key}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.cacheTTL) {
return cached.value as T;
}
const result = await this.fetchFromApi<T>(namespace, key);
if (result !== null) {
this.cache.set(cacheKey, { value: result, timestamp: Date.now() });
}
return result;
}
async set<T>(namespace: string, key: string, value: T): Promise<void> {
await this.putToApi(namespace, key, value);
// Write-through: update cache immediately
this.cache.set(`${namespace}::${key}`, { value, timestamp: Date.now() });
}
}
```
Cache invalidation happens on `set`, `delete`, and `clear`. The `query` method always bypasses cache and fetches fresh data.
---
## Cross-Tab Synchronization
### Problem
When using `localStorage`, multiple browser tabs may read stale data if one tab writes and another has already loaded its state.
### Solution
The `LocalStorageAdapter` listens to the `storage` event, which fires when another tab modifies `localStorage`:
```typescript
// src/lib/storage/adapters/local-storage.ts
class LocalStorageAdapter implements StorageService {
private listeners: Map<string, Set<(key: string) => void>> = new Map();
constructor() {
if (typeof window !== 'undefined') {
window.addEventListener('storage', this.handleStorageEvent);
}
}
private handleStorageEvent = (event: StorageEvent) => {
if (!event.key?.startsWith('architools::')) return;
const [, namespace, key] = event.key.split('::');
const nsListeners = this.listeners.get(namespace);
if (nsListeners) {
nsListeners.forEach(listener => listener(key));
}
};
/** Subscribe to changes in a namespace from other tabs. */
onExternalChange(namespace: string, callback: (key: string) => void): () => void {
if (!this.listeners.has(namespace)) {
this.listeners.set(namespace, new Set());
}
this.listeners.get(namespace)!.add(callback);
// Return unsubscribe function
return () => {
this.listeners.get(namespace)?.delete(callback);
};
}
destroy() {
if (typeof window !== 'undefined') {
window.removeEventListener('storage', this.handleStorageEvent);
}
}
}
```
### Hook Integration
```typescript
export function useStorageSync(namespace: string, onSync: () => void) {
const { service } = useContext(StorageContext)!;
useEffect(() => {
if ('onExternalChange' in service) {
const unsubscribe = (service as LocalStorageAdapter).onExternalChange(
namespace,
() => onSync()
);
return unsubscribe;
}
}, [service, namespace, onSync]);
}
```
Modules that display lists (registratura, address book) should call `useStorageSync` to re-fetch data when another tab makes changes.
### Limitations
- The `storage` event only fires in _other_ tabs, not the tab that made the change.
- The API adapter does not support cross-tab sync natively. If needed, it would require WebSocket or polling.
- Cross-tab sync is best-effort, not transactional. Two tabs writing the same key simultaneously can cause last-write-wins conflicts.
---
## File Structure
```
src/lib/storage/
├── types.ts # StorageService interface, NamespaceMeta
├── errors.ts # Error classes
├── factory.ts # createDefaultAdapter()
├── provider.tsx # StorageProvider context
├── hooks.ts # useStorage, useStorageSync
├── constants.ts # Namespace list, quota defaults
└── adapters/
├── local-storage.ts # LocalStorageAdapter
├── api-storage.ts # ApiStorageAdapter (stub)
└── minio-storage.ts # MinioAdapter (stub)
```
---
## Testing
- Adapters are tested with a shared test suite that runs against the `StorageService` interface. Swap the adapter instance to test each implementation identically.
- `LocalStorageAdapter` tests use a mock `localStorage` (e.g., `jest-localstorage-mock` or a simple in-memory Map).
- Integration tests verify namespace isolation: writing to namespace A must never affect namespace B.
- Quota tests verify that `StorageQuotaExceededError` is thrown at the correct thresholds.

View File

@@ -0,0 +1,702 @@
# ArchiTools — System Architecture
## 1. Platform Overview
ArchiTools is a modular internal web dashboard platform built for a group of architecture, urban design, and engineering companies based in Cluj-Napoca, Romania:
- **Beletage SRL** — architecture office
- **Urban Switch SRL** — architecture and urban projects
- **Studii de Teren SRL** — engineering, surveying, GIS, technical studies
The platform centralizes daily operational tools: document registries, generators, templates, inventories, AI-assisted workflows, and technical utilities. It replaces scattered standalone HTML tools and manual processes with a unified, themeable, module-driven dashboard.
**Key constraints:**
- Internal-first deployment (external/guest access planned for later phases)
- Romanian UI labels; English code and comments
- On-premise Docker deployment behind reverse proxy
- localStorage as initial persistence layer, abstracted for future database/object store migration
- Must function as a **module platform**, not a monolithic application
---
## 2. High-Level Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ NGINX PROXY MANAGER │
│ (TLS termination, routing) │
└──────────────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ DOCKER CONTAINER (Next.js) │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ PRESENTATION LAYER │ │
│ │ App Shell │ Sidebar │ Theme │ i18n │ Module Routes │ │
│ └──────────────────────────┬────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────┴────────────────────────────────────┐ │
│ │ MODULE LAYER │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │Registra- │ │ Email │ │ Word XML │ │ Prompt │ ... │ │
│ │ │ tura │ │Signature │ │Generator │ │Generator │ │ │
│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │
│ │ │ │ │ │ │ │
│ └───────┼─────────────┼────────────┼─────────────┼──────────────┘ │
│ │ │ │ │ │
│ ┌───────┴─────────────┴────────────┴─────────────┴──────────────┐ │
│ │ CORE SERVICES LAYER │ │
│ │ │ │
│ │ Module Registry │ Feature Flags │ Storage Abstraction │ │
│ │ Tagging System │ i18n Engine │ Theme Provider │ │
│ │ Auth Stub │ Navigation │ Config │ │
│ └──────────────────────────┬────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────┴────────────────────────────────────┐ │
│ │ STORAGE LAYER │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ localStorage │ │ MinIO │ │ Database │ │ │
│ │ │ (current) │ │ (planned) │ │ (planned) │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
┌──────────────────┼──────────────────┐
▼ ▼ ▼
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Authentik │ │ MinIO │ │ N8N │
│ (SSO) │ │ (obj store) │ │ (automation) │
└─────────────┘ └──────────────┘ └──────────────┘
```
---
## 3. Core Architecture Principles
### 3.1 Module Platform, Not Monolith
The application is a **platform that hosts modules**. The shell (layout, sidebar, theme, navigation) exists to load and present modules. No module is assumed to exist at build time — the system must function with zero modules enabled.
### 3.2 Module Isolation
Each module owns its:
- Route subtree (`/app/(modules)/[module-name]/`)
- Business logic (`/modules/[module-name]/`)
- Types, services, hooks, and components
- Storage namespace (scoped key prefix)
- Configuration entry in the module registry
Modules must never import from another module's internal directories. Cross-module communication happens exclusively through core services (tagging system, storage abstraction, shared hooks).
### 3.3 Removability
Disabling a module via feature flag must not produce build errors, runtime errors, or broken navigation. This is enforced by:
- Config-driven navigation (only enabled modules appear)
- Dynamic imports for module routes
- No direct cross-module imports
- Feature flag guards at route and component boundaries
### 3.4 Storage Independence
No module or component may call `localStorage`, `sessionStorage`, or any browser storage API directly. All persistence flows through the storage abstraction layer, which resolves to the active adapter at runtime.
### 3.5 Presentation/Logic Separation
UI components receive data via props and hooks. Business logic lives in `services/` (pure functions) and `hooks/` (stateful logic). Components do not contain data transformation, validation, or persistence logic.
---
## 4. Layer Architecture
```
┌─────────────────────────────────────────────────────┐
│ PRESENTATION LAYER │
│ Next.js App Router pages, layouts, UI components │
│ shadcn/ui primitives, Tailwind styling │
│ Theme provider, i18n labels │
├─────────────────────────────────────────────────────┤
│ MODULE LAYER │
│ Module-specific components, hooks, services │
│ Module config, types, route pages │
│ Isolated per module, lazy-loadable │
├─────────────────────────────────────────────────────┤
│ CORE SERVICES LAYER │
│ Module registry, feature flags, navigation │
│ Storage abstraction, tagging, auth stub │
│ i18n engine, theme system, config │
├─────────────────────────────────────────────────────┤
│ STORAGE / INTEGRATION LAYER │
│ Storage adapters (localStorage, MinIO, DB, API) │
│ External service clients │
│ Environment configuration │
└─────────────────────────────────────────────────────┘
```
### Layer Rules
| Layer | May Import From | Must Not Import From |
|-------|----------------|---------------------|
| Presentation | Module Layer, Core Services, Shared | — |
| Module Layer | Core Services, Shared | Other modules |
| Core Services | Shared, Config | Module Layer, Presentation |
| Storage/Integration | Config only | Everything else |
---
## 5. Runtime Architecture
### 5.1 Next.js App Router
The application uses Next.js 15 with the App Router. The routing structure:
```
app/
├── layout.tsx → Root layout: providers, shell wrapper
├── page.tsx → Dashboard home (widget grid)
├── globals.css → Tailwind base + custom tokens
└── (modules)/ → Route group (no URL segment)
├── registratura/
│ ├── page.tsx → Module entry page
│ └── [id]/
│ └── page.tsx → Detail view
├── email-signature/
│ └── page.tsx
└── ...
```
### 5.2 Client-Side Primary, SSR Where Needed
The platform is primarily a client-side interactive application. Most module pages use `"use client"` directives because they:
- Manage complex form state
- Interact with browser storage
- Require immediate user feedback
- Handle drag-and-drop, clipboard, and other browser APIs
Server-side rendering is used for:
- The shell layout (static structure, fast first paint)
- Metadata generation
- Any future API routes serving data to external consumers
### 5.3 Provider Stack
The root layout wraps the application in a provider stack:
```
<ThemeProvider>
<I18nProvider>
<StorageProvider>
<FeatureFlagProvider>
<AppShell>
{children}
</AppShell>
</FeatureFlagProvider>
</StorageProvider>
</I18nProvider>
</ThemeProvider>
```
Each provider is independent and can be replaced or extended without affecting others.
---
## 6. Module Isolation Model
### 6.1 Module Structure
Every module follows a standard directory structure:
```
src/modules/[module-name]/
├── components/ # Module-specific React components
├── hooks/ # Module-specific React hooks
├── services/ # Pure business logic functions
├── types.ts # TypeScript interfaces and types
├── config.ts # Module metadata for the registry
└── index.ts # Public API barrel export
```
### 6.2 Module Registration
Each module exports a `ModuleConfig` object:
```typescript
interface ModuleConfig {
id: string; // Unique identifier (kebab-case)
name: string; // Romanian display name
description: string; // Romanian description
icon: string; // Lucide icon name
route: string; // Base route path
category: ModuleCategory; // Grouping for navigation
enabled: boolean; // Default enabled state
featureFlag: string; // Flag key in feature flag system
requiredRole?: UserRole; // Minimum role (future use)
visibility?: Visibility; // internal | admin | public
version: string; // Semver
storageNamespace: string; // Storage key prefix
}
```
The central module registry (`src/config/modules.ts`) imports all module configs and provides them to the navigation system and feature flag guards.
### 6.3 Module Lifecycle
```
1. Module config registered in modules.ts
2. Feature flag checked at runtime
3. If enabled: route is accessible, nav item visible
4. Module page loads (dynamic import possible)
5. Module initializes its hooks/services
6. Module reads/writes through storage abstraction
7. If disabled: route returns redirect/404, nav item hidden
```
---
## 7. Core Systems Overview
### 7.1 Module Registry
**Location:** `src/core/module-registry/`
Central catalog of all available modules. Provides:
- List of all registered modules with metadata
- Lookup by ID, route, or category
- Filtering by enabled state, visibility, role
- Module category grouping for navigation
The registry is the single source of truth for what modules exist. Navigation, routing guards, and the dashboard widget grid all read from it.
### 7.2 Feature Flags
**Location:** `src/core/feature-flags/`
Controls module activation and experimental feature visibility.
```typescript
interface FeatureFlag {
key: string;
enabled: boolean;
scope: 'module' | 'feature' | 'experiment';
requiredRole?: UserRole;
description: string;
}
```
Flag resolution order:
1. Environment variable override (`NEXT_PUBLIC_FLAG_*`)
2. Runtime config (`src/config/flags.ts`)
3. Default from module config
Flags are checked via the `useFeatureFlag(key)` hook and the `<FeatureGate flag="key">` component wrapper.
### 7.3 Storage Abstraction
**Location:** `src/core/storage/`
All data persistence flows through a `StorageService` interface:
```typescript
interface StorageService {
get<T>(namespace: string, key: string): Promise<T | null>;
set<T>(namespace: string, key: string, value: T): Promise<void>;
delete(namespace: string, key: string): Promise<void>;
list(namespace: string): Promise<string[]>;
clear(namespace: string): Promise<void>;
}
```
**Adapters:**
| Adapter | Status | Use Case |
|---------|--------|----------|
| `LocalStorageAdapter` | Current | Browser-local persistence, demo/dev mode |
| `MinIOAdapter` | Planned | File and object storage (signatures, templates) |
| `DatabaseAdapter` | Planned | Structured data (registry entries, inventory) |
| `APIAdapter` | Planned | External service delegation |
The active adapter is resolved at startup from environment configuration. Modules never know which adapter is active.
**Namespace isolation:** Each module operates within its own namespace (e.g., `registratura:entries`, `password-vault:credentials`). Modules cannot read or write to another module's namespace without explicit cross-module service mediation.
### 7.4 Tagging System
**Location:** `src/core/tagging/`
A cross-module tagging service used by multiple modules to categorize and link entities.
Tags are structured objects:
```typescript
interface Tag {
id: string;
label: string; // Romanian display label
category: TagCategory; // project | client | domain | custom
color?: string;
metadata?: Record<string, unknown>;
createdAt: string;
}
```
The tagging system provides:
- Tag CRUD operations (stored via storage abstraction)
- Tag selector component (shared UI)
- Tag filtering and search
- Tag usage tracking across modules
Modules that use tags: Registratura, Prompt Generator, Word Templates, Digital Signatures, IT Inventory, Address Book.
### 7.5 Internationalization (i18n)
**Location:** `src/core/i18n/`
Current implementation: Romanian-only with structured label access for future multi-language support.
Labels are organized by module namespace:
```typescript
const labels = {
common: {
save: 'Salvează',
cancel: 'Anulează',
delete: 'Șterge',
search: 'Caută',
// ...
},
registratura: {
title: 'Registratură',
newEntry: 'Înregistrare nouă',
// ...
},
};
```
Access pattern: `useLabel('registratura.newEntry')` or `<Label k="common.save" />`.
The system is designed so that adding a second language requires only adding translation files, not changing component code.
### 7.6 Theme System
**Location:** `src/core/theme/`
Dark/light theme support using CSS custom properties and Tailwind's `dark:` variant.
Design tokens:
- Background and surface colors
- Text hierarchy (primary, secondary, muted)
- Border and divider colors
- Accent colors per company (Beletage, Urban Switch, Studii de Teren)
- Semantic colors (success, warning, error, info)
Theme preference is persisted in storage and respects system preference as default. The theme provider exposes `useTheme()` with `theme`, `setTheme`, and `toggleTheme`.
Visual style: professional, technical, card-based dashboard. No playful or consumer-oriented aesthetics.
### 7.7 Auth Stub
**Location:** `src/core/auth/`
Current state: no authentication enforced (internal network only).
The auth module provides a stub interface that modules can code against:
```typescript
interface AuthContext {
user: User | null;
role: UserRole; // 'admin' | 'user' | 'guest'
isAuthenticated: boolean;
company: CompanyId | null;
permissions: string[];
}
```
In the current phase, `AuthContext` returns a default internal user with admin role. When Authentik SSO integration is implemented, the auth module will resolve real user identity from SSO tokens without any module code changes.
Data model fields (`visibility`, `requiredRole`, `createdBy`) are included from day one so that enabling auth does not require data migration.
---
## 8. External Integration Points
### 8.1 Current Infrastructure
ArchiTools runs alongside existing services on the internal network:
| Service | Integration Type | Purpose |
|---------|-----------------|---------|
| **Authentik** | Future SSO provider | User authentication and role assignment |
| **MinIO** | Future storage adapter | Object/file storage for documents, signatures, templates |
| **N8N** | Future webhook/API | Workflow automation (document processing, notifications) |
| **Gitea** | Development | Source code hosting |
| **Stirling PDF** | Dashboard link | PDF manipulation (external tool link) |
| **IT-Tools** | Dashboard link | Technical utilities (external tool link) |
| **Filebrowser** | Dashboard link | File management (external tool link) |
| **Uptime Kuma** | Dashboard widget | Service health status |
| **Netdata** | Dashboard widget | Server performance metrics |
### 8.2 Integration Patterns
**Dashboard links:** External tools appear as navigation entries or dashboard widgets with `target="_blank"` links. No embedding or API integration needed.
**Dashboard widgets:** Services like Uptime Kuma and Netdata can expose status endpoints or embed iframes for health/monitoring widgets on the dashboard home.
**Storage integration (MinIO):** When the MinIO adapter is implemented, modules that manage files (Digital Signatures, Word Templates) will store binary assets in MinIO buckets while keeping metadata in the primary storage.
**Automation integration (N8N):** Modules can trigger N8N webhooks for automated workflows. Example: Registratura creates a new entry, triggering an N8N workflow that sends a notification or generates a document.
**SSO integration (Authentik):** The auth stub will be replaced with an Authentik OIDC client. The middleware layer will validate tokens and populate `AuthContext`. No module code changes required.
---
## 9. Data Flow Patterns
### 9.1 Module Data Read
```
User action
→ Component calls hook (e.g., useRegistryEntries())
→ Hook calls service function (e.g., getEntries())
→ Service calls StorageService.get(namespace, key)
→ StorageService resolves active adapter
→ Adapter reads from storage backend
→ Data returns up the chain
→ Hook updates state
→ Component re-renders
```
### 9.2 Module Data Write
```
User submits form
→ Component calls hook mutation (e.g., createEntry(data))
→ Hook validates via service (e.g., validateEntry(data))
→ Service calls StorageService.set(namespace, key, data)
→ Adapter writes to storage backend
→ Hook updates local state / invalidates cache
→ Component re-renders with new data
```
### 9.3 Cross-Module Data (via Tagging)
```
User tags an entity in Module A
→ Module A calls TaggingService.addTag(entityId, tagId)
→ Tag association stored in tagging namespace
User filters by tag in Module B
→ Module B calls TaggingService.getEntitiesByTag(tagId)
→ Returns entity IDs across modules
→ Module B fetches its own entities matching those IDs
```
### 9.4 Feature Flag Check
```
Route or component renders
→ <FeatureGate flag="module.registratura">
→ useFeatureFlag('module.registratura')
→ Checks env override → config → default
→ Returns boolean
→ Children render or fallback shown
```
---
## 10. Deployment Architecture
### 10.1 Container Structure
```
┌──────────────────────────────────────────────────┐
│ Ubuntu Server (on-premise) │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Docker (via Portainer) │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────────────┐ │ │
│ │ │ ArchiTools │ │ Other containers │ │ │
│ │ │ (Next.js) │ │ (Authentik, MinIO, │ │ │
│ │ │ Port: 3000 │ │ N8N, Gitea, etc.) │ │ │
│ │ └──────┬───────┘ └──────────────────────┘ │ │
│ │ │ │ │
│ └─────────┼────────────────────────────────────┘ │
│ │ │
│ ┌─────────┴─────────────────────────────────────┐ │
│ │ Nginx Proxy Manager │ │
│ │ tools.internal.domain → localhost:3000 │ │
│ └────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
```
### 10.2 Docker Configuration
**Dockerfile:** Multi-stage build.
1. **deps stage** — installs Node.js dependencies
2. **build stage** — runs `next build`, produces standalone output
3. **runtime stage** — minimal Node.js image, copies standalone build, exposes port 3000
**docker-compose.yml:** Single service definition for ArchiTools. Environment variables passed from `.env` file. Optional volume mounts for persistent data if needed beyond localStorage.
### 10.3 Environment Variables
```
# Application
NEXT_PUBLIC_APP_URL=https://tools.internal.domain
NEXT_PUBLIC_APP_ENV=production
# Feature flags (override defaults)
NEXT_PUBLIC_FLAG_MODULE_REGISTRATURA=true
NEXT_PUBLIC_FLAG_MODULE_AI_CHAT=false
# Future: Storage backend
STORAGE_BACKEND=localStorage
MINIO_ENDPOINT=minio.internal.domain
MINIO_ACCESS_KEY=...
MINIO_SECRET_KEY=...
# Future: Auth
AUTHENTIK_ISSUER=https://auth.internal.domain
AUTHENTIK_CLIENT_ID=...
AUTHENTIK_CLIENT_SECRET=...
```
### 10.4 Build and Deploy Flow
```
Developer pushes to Gitea
→ (future: CI pipeline builds image)
→ Docker image built (manual or Watchtower auto-update)
→ Portainer deploys/restarts container
→ Nginx Proxy Manager routes traffic
→ Users access via internal domain
```
---
## 11. Scalability Considerations
### 11.1 Current Scale
- **Users:** ~520 internal staff across three companies
- **Data volume:** Low (hundreds to low thousands of records per module)
- **Concurrency:** Minimal (localStorage is per-browser, no shared state conflicts)
### 11.2 Growth Path
| Concern | Current | Growth Path |
|---------|---------|-------------|
| Data persistence | localStorage (per-browser) | Database + MinIO (shared, centralized) |
| Authentication | None (network trust) | Authentik SSO with RBAC |
| Multi-user data | Isolated per browser | Centralized with user ownership |
| File storage | Not supported | MinIO buckets per module |
| Search | Client-side filter | Server-side indexed search |
| API access | None | Next.js API routes for external consumers |
| Automation | Manual | N8N webhooks triggered by module events |
### 11.3 Module Scaling
New modules are added by:
1. Creating the module directory structure
2. Registering the module config
3. Adding the feature flag
4. Creating the route pages
No changes to the shell, navigation, or other modules are required. The navigation rebuilds itself from the registry.
---
## 12. Security Boundaries
### 12.1 Current Phase: Internal Network Trust
```
┌─────────────────────────────────────────────┐
│ Internal Network │
│ │
│ ┌──────────┐ ┌──────────────────────┐ │
│ │ Users │────▶│ ArchiTools │ │
│ │ (trusted) │ │ (no auth required) │ │
│ └──────────┘ └──────────────────────┘ │
│ │
│ Security: network-level only │
│ Data: browser-local, no shared secrets │
│ Risk: low (internal, trusted users) │
└─────────────────────────────────────────────┘
```
**Current security model:**
- Network perimeter security via Crowdsec and firewall rules
- No application-level authentication
- No sensitive data in localStorage (password vault uses demo-grade encryption)
- No external API endpoints exposed
- All data stays in the user's browser
### 12.2 Future Phase: SSO + Role-Based Access
```
┌──────────────────────────────────────────────────────────┐
│ │
│ ┌──────────┐ ┌────────────┐ ┌──────────────────┐│
│ │ Users │───▶│ Authentik │───▶│ ArchiTools ││
│ │(internal/ │ │ (SSO) │ │ (auth enforced) ││
│ │ external) │ └────────────┘ └──────────────────┘│
│ └──────────┘ │
│ │
│ Security: SSO + RBAC + module permissions │
│ Data: centralized DB + MinIO with access control │
│ Roles: admin, user, guest │
│ Visibility: per-field, per-module, per-company │
└──────────────────────────────────────────────────────────┘
```
**Planned security layers:**
- Authentik OIDC authentication (SSO)
- Role-based module access (admin, user, guest)
- Company-scoped data visibility
- Per-field visibility metadata (internal, admin, public)
- API route protection via middleware token validation
- Audit logging for sensitive operations
**Design-for-security decisions made now:**
- All data models include `visibility` and `createdBy` fields
- Module configs include `requiredRole` field
- Feature flags support role-based activation
- Auth context interface defined (stubbed with defaults)
- Storage namespace isolation prevents cross-module data leaks
---
## Appendix A: Technology Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Framework | Next.js 15 (App Router) | Modern React with file-based routing, SSR capability, API routes |
| Language | TypeScript | Type safety across modules, better refactorability |
| Styling | Tailwind CSS | Utility-first, consistent with shadcn/ui, theme support |
| Component library | shadcn/ui | Copy-paste components, full control, professional aesthetic |
| Deployment | Docker | Consistent with existing infrastructure (Portainer) |
| Initial storage | localStorage | Zero infrastructure, immediate development start |
| Storage pattern | Adapter abstraction | Allows migration without module changes |
| Auth pattern | Stub with interface | Enables SSO integration without refactoring |
## Appendix B: Module Catalog
| Module | ID | Category | Status |
|--------|----|----------|--------|
| Dashboard | `dashboard` | Core | Planned |
| Registratura | `registratura` | Registry | Planned |
| Email Signature Generator | `email-signature` | Generators | Planned (legacy exists) |
| Word XML Generators | `word-xml` | Generators | Planned (legacy exists) |
| Digital Signatures & Stamps | `digital-signatures` | Assets | Planned |
| Password Vault | `password-vault` | Security | Planned |
| IT Inventory | `it-inventory` | Infrastructure | Planned |
| Address Book | `address-book` | Contacts | Planned |
| Prompt Generator | `prompt-generator` | AI Tools | Planned |
| Word Template Library | `word-templates` | Templates | Planned |
| Tag Manager | `tag-manager` | Administration | Planned |
| Mini Utilities | `mini-utilities` | Tools | Planned |
| AI Chat | `ai-chat` | AI Tools | Planned |

View File

@@ -0,0 +1,571 @@
# Tagging System
> ArchiTools internal architecture reference -- cross-module tagging, tag model, and Tag Manager module.
---
## Overview
ArchiTools uses a unified tagging system across all modules. Tags provide categorization, filtering, cross-referencing, and visual identification for entities throughout the platform. The tagging system is inspired by the existing ManicTime tag structure used by the office group and extends it into a structured, typed model.
The Tag Manager module provides a dedicated CRUD interface for managing tags. All other modules consume tags through a shared `TagService` and a set of reusable UI components.
---
## Tag Model
```typescript
// src/types/tags.ts
interface Tag {
id: string; // unique identifier (UUID v4)
label: string; // display text (Romanian), e.g. "Verificare proiect"
category: TagCategory; // semantic grouping
color?: string; // hex color for visual distinction, e.g. "#2563eb"
icon?: string; // optional Lucide icon name, e.g. "building"
scope: TagScope; // visibility scope
moduleId?: string; // if scope is 'module', which module owns this tag
companyId?: CompanyId; // if scope is 'company', which company owns this tag
parentId?: string; // for hierarchical tags (references another Tag's id)
metadata?: Record<string, string>; // extensible key-value pairs
createdAt: string; // ISO 8601
updatedAt: string; // ISO 8601
}
```
### Tag Categories
```typescript
type TagCategory =
| 'project' // project identifiers (e.g., "076 Casa Copernicus")
| 'phase' // project phases (CU, DTAC, PT, etc.)
| 'activity' // work activities (Releveu, Design interior, etc.)
| 'document-type' // document classification (Regulament, Parte desenata, etc.)
| 'company' // company association (Beletage, Urban Switch, Studii de Teren)
| 'priority' // priority levels (Urgent, Normal, Scazut)
| 'status' // status indicators (In lucru, Finalizat, In asteptare)
| 'custom'; // user-defined tags that don't fit other categories
```
Each category serves a distinct semantic purpose. Tags within the same category are mutually comparable -- you can filter all `phase` tags to get a list of project phases, or all `activity` tags to see work activities. Categories are fixed in code; adding a new category requires a code change. The `custom` category is the escape hatch for tags that do not fit the predefined categories.
### Tag Scope
```typescript
type TagScope = 'global' | 'module' | 'company';
```
| Scope | Meaning | Example |
|---|---|---|
| `global` | Available to all modules across all companies | Phase tags like "DTAC", "PT" |
| `module` | Scoped to a single module, not visible elsewhere | "Template favorit" in Prompt Generator |
| `company` | Scoped to a single company | "Ofertare" scoped to Beletage |
When `scope` is `module`, the `moduleId` field must be set. When `scope` is `company`, the `companyId` field must be set. When `scope` is `global`, both are `undefined`.
---
## Hierarchical Tags
Tags support parent-child relationships through the `parentId` field. This enables structured navigation and drill-down filtering.
### Hierarchy Convention
```
Project (category: 'project')
└── Phase (category: 'phase', parentId: project tag id)
└── Task (category: 'activity', parentId: phase tag id)
```
**Example:**
```
076 Casa Copernicus (project)
├── CU (phase)
│ ├── Redactare (activity)
│ └── Depunere (activity)
├── DTAC (phase)
│ ├── Redactare (activity)
│ ├── Verificare proiect (activity)
│ └── Vizita santier (activity)
└── PT (phase)
└── Detalii de Executie (activity)
```
### Hierarchy Rules
- A tag's `parentId` must reference an existing tag.
- Deleting a parent tag does not cascade-delete children. Children become root-level tags (their `parentId` is cleared).
- Maximum nesting depth: 3 levels. This is enforced in the `TagService` on create/update.
- A tag's category is independent of its parent's category. A `phase` tag can be a child of a `project` tag.
### Querying the Hierarchy
```typescript
// Get all children of a tag
function getChildren(tags: Tag[], parentId: string): Tag[] {
return tags.filter((tag) => tag.parentId === parentId);
}
// Get the full ancestry chain (bottom-up)
function getAncestors(tags: Tag[], tagId: string): Tag[] {
const tagMap = new Map(tags.map((t) => [t.id, t]));
const ancestors: Tag[] = [];
let current = tagMap.get(tagId);
while (current?.parentId) {
const parent = tagMap.get(current.parentId);
if (parent) ancestors.push(parent);
current = parent;
}
return ancestors.reverse(); // root-first order
}
// Build a tree structure from flat tag list
function buildTagTree(tags: Tag[]): TagTreeNode[] {
const map = new Map<string, TagTreeNode>();
const roots: TagTreeNode[] = [];
for (const tag of tags) {
map.set(tag.id, { tag, children: [] });
}
for (const tag of tags) {
const node = map.get(tag.id)!;
if (tag.parentId && map.has(tag.parentId)) {
map.get(tag.parentId)!.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
interface TagTreeNode {
tag: Tag;
children: TagTreeNode[];
}
```
---
## TagService
The `TagService` is the central data access layer for tags. All modules interact with tags exclusively through this service.
```typescript
// src/lib/tags/tag-service.ts
interface TagService {
/** Get all tags, optionally filtered by scope */
getAllTags(): Promise<Tag[]>;
/** Get tags belonging to a specific category */
getTagsByCategory(category: TagCategory): Promise<Tag[]>;
/** Get tags by scope, with optional scope identifier */
getTagsByScope(scope: TagScope, scopeId?: string): Promise<Tag[]>;
/** Get a single tag by ID */
getTag(id: string): Promise<Tag | null>;
/** Get all children of a parent tag */
getChildTags(parentId: string): Promise<Tag[]>;
/** Create a new tag. Validates uniqueness of label within category+scope. */
createTag(tag: Omit<Tag, 'id' | 'createdAt' | 'updatedAt'>): Promise<Tag>;
/** Update an existing tag. Partial updates supported. */
updateTag(id: string, updates: Partial<Omit<Tag, 'id' | 'createdAt'>>): Promise<Tag>;
/** Delete a tag. Clears parentId on children. */
deleteTag(id: string): Promise<void>;
/** Full-text search on tag labels */
searchTags(query: string): Promise<Tag[]>;
/** Bulk import tags (used by Tag Manager for import/export) */
importTags(tags: Omit<Tag, 'id' | 'createdAt' | 'updatedAt'>[]): Promise<Tag[]>;
/** Export all tags as a serializable array */
exportTags(): Promise<Tag[]>;
}
```
### Storage
Tags are stored under the `architools.tags` namespace in the storage abstraction layer. The storage key layout:
```
architools.tags.all -> Tag[] (master list)
architools.tags.index -> Record<TagCategory, string[]> (category -> tag ID index)
```
The index is a denormalized lookup table rebuilt on every write operation. It allows `getTagsByCategory` to resolve without scanning the full tag list.
### Validation Rules
| Rule | Enforcement |
|---|---|
| Label must be non-empty and <= 100 characters | `createTag`, `updateTag` |
| Label must be unique within the same `category` + `scope` + `scopeId` | `createTag`, `updateTag` |
| `moduleId` required when `scope` is `module` | `createTag`, `updateTag` |
| `companyId` required when `scope` is `company` | `createTag`, `updateTag` |
| `parentId` must reference an existing tag | `createTag`, `updateTag` |
| Max nesting depth: 3 | `createTag`, `updateTag` |
| `color` must be a valid 6-digit hex (`#rrggbb`) | `createTag`, `updateTag` |
| `icon` must be a valid Lucide icon name | `createTag`, `updateTag` |
---
## Tag UI Components
Four reusable components provide the tag interface across all modules.
### `TagBadge`
Displays a single tag as a colored chip.
```typescript
// src/components/shared/tags/TagBadge.tsx
interface TagBadgeProps {
tag: Tag;
size?: 'sm' | 'md'; // default: 'sm'
removable?: boolean; // shows X button
onRemove?: (tagId: string) => void;
}
```
Renders a rounded badge with the tag's `color` as background (with opacity for readability), the `icon` if present, and the `label` as text. The `sm` size is used inline in tables and lists; `md` is used in detail views and forms.
### `TagSelector`
Multi-select tag picker with category filtering, search, and tag creation.
```typescript
// src/components/shared/tags/TagSelector.tsx
interface TagSelectorProps {
selectedTagIds: string[];
onChange: (tagIds: string[]) => void;
categories?: TagCategory[]; // restrict to specific categories
scope?: TagScope; // restrict to specific scope
scopeId?: string; // module or company ID for scope filtering
allowCreate?: boolean; // allow inline tag creation (default: false)
placeholder?: string;
maxTags?: number; // max selectable tags (default: unlimited)
}
```
**Behavior:**
- Opens a popover with a search input and categorized tag list.
- Tags are grouped by category with category headers.
- Search filters tags by label (case-insensitive, diacritics-insensitive).
- Selected tags appear as `TagBadge` components above the input.
- When `allowCreate` is true, typing a label that does not match any existing tag shows a "Creeaza tag: [label]" option.
### `TagFilter`
Filter bar for lists and tables. Allows users to select tags and see only entities matching those tags.
```typescript
// src/components/shared/tags/TagFilter.tsx
interface TagFilterProps {
activeTags: string[]; // currently active filter tag IDs
onChange: (tagIds: string[]) => void;
categories?: TagCategory[]; // which categories to show filter chips for
tagCounts?: Record<string, number>; // tag ID -> count of matching entities
mode?: 'and' | 'or'; // filter logic (default: 'or')
}
```
**Behavior:**
- Displays as a horizontal chip bar above the list/table.
- Each active tag is shown as a `TagBadge` with a remove button.
- A "+" button opens the `TagSelector` to add more filter tags.
- When `tagCounts` is provided, each tag shows its count in parentheses.
- `mode: 'and'` requires entities to match all selected tags; `mode: 'or'` matches any.
### `TagManager`
This is the root component of the **Tag Manager module** -- a dedicated interface for full CRUD operations on the global tag catalog.
**Capabilities:**
- Browse all tags in a searchable, filterable table.
- Create, edit, and delete tags.
- Bulk operations: delete multiple tags, change category, change scope.
- Import tags from JSON or CSV.
- Export all tags to JSON.
- Visual hierarchy browser: tree view of parent-child relationships.
- Color picker and icon selector for tag customization.
The Tag Manager module config:
```typescript
{
id: 'tag-manager',
name: 'Manager Etichete',
description: 'Gestionare centralizata a etichetelor folosite in toate modulele.',
icon: 'tags',
route: '/tag-manager',
category: 'management',
featureFlag: 'module.tag-manager',
visibility: 'internal',
version: '1.0.0',
storageNamespace: 'architools.tag-manager',
navOrder: 40,
tags: ['tags', 'management', 'configuration'],
}
```
---
## Pre-Seeded Tags
The following tags are seeded on first initialization, derived from the existing ManicTime tag structure used by the office group. These provide immediate utility without requiring manual setup.
### Project Phases (`category: 'phase'`, `scope: 'global'`)
| Label | Color | Description |
|---|---|---|
| CU | `#3b82f6` | Certificat de Urbanism |
| Schita | `#8b5cf6` | Schita de proiect |
| Avize | `#06b6d4` | Obtinere avize |
| PUD | `#10b981` | Plan Urbanistic de Detaliu |
| AO | `#f59e0b` | Autorizatie de Construire (obtinere) |
| PUZ | `#ef4444` | Plan Urbanistic Zonal |
| PUG | `#ec4899` | Plan Urbanistic General |
| DTAD | `#6366f1` | Documentatie Tehnica pentru Autorizatia de Desfiintare |
| DTAC | `#14b8a6` | Documentatie Tehnica pentru Autorizatia de Construire |
| PT | `#f97316` | Proiect Tehnic |
| Detalii de Executie | `#84cc16` | Detalii de executie |
### Activities (`category: 'activity'`, `scope: 'global'`)
| Label | Color |
|---|---|
| Redactare | `#6366f1` |
| Depunere | `#10b981` |
| Ridicare | `#f59e0b` |
| Verificare proiect | `#ef4444` |
| Vizita santier | `#8b5cf6` |
| Releveu | `#3b82f6` |
| Reclama | `#ec4899` |
| Design grafic | `#06b6d4` |
| Design interior | `#14b8a6` |
| Design exterior | `#84cc16` |
### Document Types (`category: 'document-type'`, `scope: 'global'`)
| Label | Color |
|---|---|
| Regulament | `#6366f1` |
| Parte desenata | `#10b981` |
| Parte scrisa | `#3b82f6` |
### Company Tags (`category: 'company'`, `scope: 'global'`)
| Label | Color | CompanyId |
|---|---|---|
| Beletage | `#2563eb` | `beletage` |
| Urban Switch | `#16a34a` | `urban-switch` |
| Studii de Teren | `#dc2626` | `studii-de-teren` |
### Priority Tags (`category: 'priority'`, `scope: 'global'`)
| Label | Color |
|---|---|
| Urgent | `#ef4444` |
| Normal | `#3b82f6` |
| Scazut | `#6b7280` |
### Status Tags (`category: 'status'`, `scope: 'global'`)
| Label | Color |
|---|---|
| In lucru | `#f59e0b` |
| Finalizat | `#10b981` |
| In asteptare | `#6b7280` |
| Anulat | `#ef4444` |
### Seeding Implementation
```typescript
// src/lib/tags/seed-tags.ts
import type { Tag, TagCategory } from '@/types/tags';
const SEED_TAGS: Omit<Tag, 'id' | 'createdAt' | 'updatedAt'>[] = [
{ label: 'CU', category: 'phase', scope: 'global', color: '#3b82f6' },
{ label: 'DTAC', category: 'phase', scope: 'global', color: '#14b8a6' },
// ... all tags from tables above
];
export async function seedTagsIfEmpty(tagService: TagService): Promise<void> {
const existing = await tagService.getAllTags();
if (existing.length > 0) return; // only seed into empty storage
await tagService.importTags(SEED_TAGS);
}
```
The seeding runs once on application startup. If any tags already exist, seeding is skipped entirely. Users can reset to defaults from the Tag Manager module.
---
## Cross-Module Usage
Every module that supports tagging stores tag IDs as a `string[]` on its entities. The actual tag data lives in the shared tag storage, not duplicated per module.
### Registratura
```typescript
interface RegistryEntry {
id: string;
// ... other fields
tagIds: string[]; // typically: project + document-type + phase tags
}
```
**Typical tagging pattern:** A registry entry for a building permit application might carry tags `["076 Casa Copernicus", "DTAC", "Depunere", "Parte scrisa"]`.
### Prompt Generator
```typescript
interface PromptTemplate {
id: string;
// ... other fields
tagIds: string[]; // typically: activity + custom domain tags
}
```
**Typical tagging pattern:** An architectural prompt template might carry `["Design exterior", "Rendering"]`.
### IT Inventory
```typescript
interface InventoryDevice {
id: string;
// ... other fields
tagIds: string[]; // typically: company + location tags
}
```
**Typical tagging pattern:** A laptop entry might carry `["Beletage", "Birou Centru"]`.
### Address Book
```typescript
interface Contact {
id: string;
// ... other fields
tagIds: string[]; // typically: custom contact-type tags
}
```
**Typical tagging pattern:** A contact might carry `["Client", "Beletage"]` or `["Furnizor", "Materiale constructii"]`.
### Digital Signatures
```typescript
interface SignatureRecord {
id: string;
// ... other fields
tagIds: string[]; // typically: company + signer identity tags
}
```
### Integration Pattern
All modules follow the same pattern for tag integration:
1. Entity has a `tagIds: string[]` field.
2. Forms include a `<TagSelector>` for editing tags.
3. List/table views include a `<TagFilter>` for filtering by tags.
4. Detail views render tags as `<TagBadge>` components.
5. The module's service does not resolve tag data -- the UI layer calls `TagService.getTag()` or uses the `useTagsById(ids)` hook.
```typescript
// src/hooks/useTags.ts
/** Resolve an array of tag IDs into Tag objects */
function useTagsById(tagIds: string[]): Tag[] {
const [tags, setTags] = useState<Tag[]>([]);
useEffect(() => {
const tagService = getTagService();
tagService.getAllTags().then((allTags) => {
const tagMap = new Map(allTags.map((t) => [t.id, t]));
setTags(tagIds.map((id) => tagMap.get(id)).filter(Boolean) as Tag[]);
});
}, [tagIds]);
return tags;
}
```
---
## Tag Auto-Suggest
The `TagSelector` component supports contextual auto-suggestion. When a module provides context about the current entity, the selector can prioritize relevant tags.
```typescript
interface TagSuggestContext {
moduleId: string; // which module is requesting suggestions
existingTagIds: string[]; // tags already applied to the entity
entityType?: string; // e.g., 'registry-entry', 'contact'
}
```
### Suggestion Algorithm
1. **Category affinity:** Suggest tags from categories most commonly used in this module. Registratura prioritizes `project`, `phase`, `document-type`. Address Book prioritizes `company`, `custom`.
2. **Co-occurrence:** Tags frequently applied alongside the already-selected tags are ranked higher. If the user selects "076 Casa Copernicus", phase tags like "DTAC" and "PT" that have been co-applied with that project before are suggested first.
3. **Recency:** Recently used tags (across all entities in the module) are ranked higher than stale ones.
4. **Hierarchy:** If a parent tag is selected, its children are suggested. If "076 Casa Copernicus" is selected, its child phase tags are surfaced.
The suggestion ranking is computed client-side from the module's entity data and the global tag list. No server-side analytics or ML is involved.
---
## Import / Export
### Export Format (JSON)
```json
{
"version": "1.0",
"exportedAt": "2025-03-15T10:30:00Z",
"tags": [
{
"label": "CU",
"category": "phase",
"scope": "global",
"color": "#3b82f6",
"parentId": null,
"metadata": {}
}
]
}
```
Export strips `id`, `createdAt`, and `updatedAt` since these are regenerated on import. The `parentId` field uses the parent's `label` + `category` as a composite key for portability (since IDs are not stable across environments).
### Import Rules
- Duplicate detection: if a tag with the same `label` + `category` + `scope` already exists, the import skips it (no overwrite).
- Parent resolution: `parentId` in the import file is a `label:category` reference, resolved to the actual ID after all tags are created.
- Validation: all import entries are validated against the same rules as `createTag`. Invalid entries are skipped and reported.
### CSV Format
For simpler workflows, tags can be imported from CSV:
```csv
label,category,scope,color,parentLabel,parentCategory
CU,phase,global,#3b82f6,,
Redactare,activity,global,#6366f1,CU,phase
```

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%;
}
```

File diff suppressed because it is too large Load Diff

3
eslint.config.mjs Normal file
View File

@@ -0,0 +1,3 @@
import nextConfig from 'eslint-config-next';
export default nextConfig;

View File

@@ -0,0 +1,456 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Configurator semnatura e-mail</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
.no-select { -webkit-user-select: none; -ms-user-select: none; user-select: none; }
input[type=range] {
-webkit-appearance: none; appearance: none; width: 100%; height: 4px;
background: #e5e7eb; border-radius: 5px; outline: none; transition: background 0.2s ease;
}
input[type=range]:hover { background: #d1d5db; }
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none; width: 12px; height: 20px;
background: #22B5AB; cursor: pointer; border-radius: 4px;
margin-top: -8px; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
transition: transform 0.1s ease-in-out, box-shadow 0.1s ease-in-out;
}
input[type=range]::-webkit-slider-thumb:active { transform: scale(1.15); box-shadow: 0 2px 6px rgba(0,0,0,0.3); }
input[type=range]::-moz-range-thumb {
width: 12px; height: 20px; background: #22B5AB; cursor: pointer;
border-radius: 4px; border: none; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
#preview-wrapper { transition: transform 0.2s ease-in-out; transform-origin: top left; }
.color-swatch {
width: 24px; height: 24px; border-radius: 9999px; cursor: pointer;
border: 2px solid transparent; transition: all 0.2s ease;
}
.color-swatch.active { border-color: #22B5AB; transform: scale(1.1); box-shadow: 0 0 0 2px white, 0 0 0 4px #22B5AB; }
.collapsible-content { max-height: 0; overflow: hidden; transition: max-height 0.3s ease-out; }
.collapsible-content.open { max-height: 1000px; /* Valoare mare pentru a permite extinderea */ }
.collapsible-trigger svg { transition: transform 0.3s ease; }
.collapsible-trigger.open svg { transform: rotate(90deg); }
</style>
</head>
<body class="bg-gray-50 text-gray-800 no-select">
<div class="container mx-auto p-4 md:p-8">
<header class="text-center mb-10">
<h1 class="text-3xl md:text-4xl font-bold text-gray-900">Configurator semnatura e-mail</h1>
</header>
<div class="flex flex-col lg:flex-row gap-8">
<!-- Panoul de control -->
<aside class="lg:w-2/5 bg-white p-6 rounded-2xl shadow-lg border border-gray-200">
<div id="controls">
<!-- Secțiunea Date Personale -->
<div class="mb-4">
<h3 class="text-lg font-semibold text-gray-800 border-b pb-2 mb-4">Date Personale</h3>
<div class="space-y-3">
<div>
<label for="input-prefix" class="block text-sm font-medium text-gray-700 mb-1">Titulatură (prefix)</label>
<input type="text" id="input-prefix" value="arh." class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-teal-500 focus:border-teal-500">
</div>
<div>
<label for="input-name" class="block text-sm font-medium text-gray-700 mb-1">Nume și Prenume</label>
<input type="text" id="input-name" value="Marius TĂRĂU" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-teal-500 focus:border-teal-500">
</div>
<div>
<label for="input-title" class="block text-sm font-medium text-gray-700 mb-1">Funcția</label>
<input type="text" id="input-title" value="Arhitect • Beletage SRL" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-teal-500 focus:border-teal-500">
</div>
<div>
<label for="input-phone" class="block text-sm font-medium text-gray-700 mb-1">Telefon (format 07xxxxxxxx)</label>
<input type="tel" id="input-phone" value="0785123433" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-teal-500 focus:border-teal-500">
</div>
</div>
</div>
<!-- Culori Text (Collapsible) -->
<div class="mb-4">
<div class="collapsible-trigger flex justify-between items-center cursor-pointer border-b pb-2 mb-2">
<h3 class="text-lg font-semibold text-gray-800">Culori Text</h3>
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</div>
<div class="collapsible-content">
<div id="color-controls" class="space-y-2 pt-2"></div>
</div>
</div>
<!-- Secțiunea Stil & Aranjare (Collapsible) -->
<div class="mb-4">
<div class="collapsible-trigger flex justify-between items-center cursor-pointer border-b pb-2 mb-2">
<h3 class="text-lg font-semibold text-gray-800">Stil & Aranjare</h3>
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</div>
<div class="collapsible-content">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-3 pt-2">
<div>
<label for="green-line-width" class="block text-sm font-medium text-gray-700 mb-2">Lungime linie verde (<span id="green-line-value">97</span>px)</label>
<input id="green-line-width" type="range" min="50" max="300" value="97">
</div>
<div>
<label for="section-spacing" class="block text-sm font-medium text-gray-700 mb-2">Spațiere vert. secțiuni (<span id="section-spacing-value">10</span>px)</label>
<input id="section-spacing" type="range" min="0" max="30" value="10">
</div>
<div>
<label for="logo-spacing" class="block text-sm font-medium text-gray-700 mb-2">Spațiere vert. Logo (<span id="logo-spacing-value">10</span>px)</label>
<input id="logo-spacing" type="range" min="0" max="30" value="10">
</div>
<div>
<label for="title-spacing" class="block text-sm font-medium text-gray-700 mb-2">Spațiere vert. funcție (<span id="title-spacing-value">2</span>px)</label>
<input id="title-spacing" type="range" min="0" max="20" value="2">
</div>
<div>
<label for="b-gutter-width" class="block text-sm font-medium text-gray-700 mb-2">Aliniere contact (<span id="b-gutter-value">13</span>px)</label>
<input id="b-gutter-width" type="range" min="0" max="150" value="13">
</div>
<div>
<label for="icon-text-spacing" class="block text-sm font-medium text-gray-700 mb-2">Spațiu Icon-Text (<span id="icon-text-spacing-value">5</span>px)</label>
<input id="icon-text-spacing" type="range" min="-10" max="30" value="5">
</div>
<div>
<label for="icon-vertical-pos" class="block text-sm font-medium text-gray-700 mb-2">Aliniere vert. iconițe (<span id="icon-vertical-value">1</span>px)</label>
<input id="icon-vertical-pos" type="range" min="-10" max="10" value="1">
</div>
<div>
<label for="motto-spacing" class="block text-sm font-medium text-gray-700 mb-2">Spațiere vert. motto (<span id="motto-spacing-value">3</span>px)</label>
<input id="motto-spacing" type="range" min="0" max="20" value="3">
</div>
</div>
</div>
</div>
<!-- Opțiuni -->
<div class="mb-4">
<h3 class="text-lg font-semibold text-gray-800 border-b pb-2 mb-4">Opțiuni</h3>
<div class="space-y-2">
<label class="flex items-center space-x-3 cursor-pointer">
<input type="checkbox" id="reply-variant-checkbox" class="h-4 w-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500">
<span class="text-sm font-medium text-gray-700">Variantă simplă (fără logo/adresă)</span>
</label>
<label class="flex items-center space-x-3 cursor-pointer">
<input type="checkbox" id="super-reply-variant-checkbox" class="h-4 w-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500">
<span class="text-sm font-medium text-gray-700">Super-simplă (doar nume/telefon)</span>
</label>
<label class="flex items-center space-x-3 cursor-pointer">
<input type="checkbox" id="use-svg-checkbox" class="h-4 w-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500">
<span class="text-sm font-medium text-gray-700">Folosește imagini SVG (calitate maximă)</span>
</label>
</div>
</div>
</div>
<!-- Buton de Export -->
<div class="mt-8 pt-6 border-t">
<button id="export-btn" class="w-full bg-teal-600 text-white font-bold py-3 px-4 rounded-lg hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-teal-500 transition-all duration-300 ease-in-out transform hover:scale-105">
Descarcă HTML
</button>
</div>
</aside>
<!-- Previzualizare Live -->
<main class="lg:w-3/5 bg-white p-6 rounded-2xl shadow-lg border border-gray-200 overflow-hidden">
<div class="flex justify-between items-center border-b pb-3 mb-4">
<h2 class="text-2xl font-bold text-gray-900">Previzualizare Live</h2>
<button id="zoom-btn" class="text-sm bg-gray-200 text-gray-700 px-3 py-1 rounded-md hover:bg-gray-300">Zoom 100%</button>
</div>
<div id="preview-wrapper" class="overflow-auto">
<div id="preview-container">
<!-- Aici este inserat codul HTML al semnăturii -->
</div>
</div>
</main>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const controls = {
prefix: document.getElementById('input-prefix'),
name: document.getElementById('input-name'),
title: document.getElementById('input-title'),
phone: document.getElementById('input-phone'),
greenLine: document.getElementById('green-line-width'),
gutter: document.getElementById('b-gutter-width'),
iconTextSpacing: document.getElementById('icon-text-spacing'),
iconVertical: document.getElementById('icon-vertical-pos'),
mottoSpacing: document.getElementById('motto-spacing'),
sectionSpacing: document.getElementById('section-spacing'),
titleSpacing: document.getElementById('title-spacing'),
logoSpacing: document.getElementById('logo-spacing'),
replyCheckbox: document.getElementById('reply-variant-checkbox'),
superReplyCheckbox: document.getElementById('super-reply-variant-checkbox'),
useSvgCheckbox: document.getElementById('use-svg-checkbox'),
exportBtn: document.getElementById('export-btn'),
zoomBtn: document.getElementById('zoom-btn'),
colorControls: document.getElementById('color-controls')
};
const values = {
greenLine: document.getElementById('green-line-value'),
gutter: document.getElementById('b-gutter-value'),
iconTextSpacing: document.getElementById('icon-text-spacing-value'),
iconVertical: document.getElementById('icon-vertical-value'),
mottoSpacing: document.getElementById('motto-spacing-value'),
sectionSpacing: document.getElementById('section-spacing-value'),
titleSpacing: document.getElementById('title-spacing-value'),
logoSpacing: document.getElementById('logo-spacing-value')
};
const previewContainer = document.getElementById('preview-container');
const previewWrapper = document.getElementById('preview-wrapper');
const imageSets = {
png: {
logo: 'https://beletage.ro/img/Semnatura-Logo.png',
greySlash: 'https://beletage.ro/img/Grey-slash.png',
greenSlash: 'https://beletage.ro/img/Green-slash.png'
},
svg: {
logo: 'https://beletage.ro/img/Logo-Beletage.svg',
greySlash: 'https://beletage.ro/img/Grey-slash.svg',
greenSlash: 'https://beletage.ro/img/Green-slash.svg'
}
};
const beletageColors = {
verde: '#22B5AB',
griInchis: '#54504F',
griDeschis: '#A7A9AA',
negru: '#323232'
};
const colorConfig = {
prefix: { label: 'Titulatură', default: beletageColors.griInchis },
name: { label: 'Nume', default: beletageColors.griInchis },
title: { label: 'Funcție', default: beletageColors.griDeschis },
address: { label: 'Adresă', default: beletageColors.griDeschis },
phone: { label: 'Telefon', default: beletageColors.griInchis },
website: { label: 'Website', default: beletageColors.griInchis },
motto: { label: 'Motto', default: beletageColors.verde }
};
let currentColors = {};
function createColorPickers() {
for (const [key, config] of Object.entries(colorConfig)) {
currentColors[key] = config.default;
const controlRow = document.createElement('div');
controlRow.className = 'flex items-center justify-between';
const label = document.createElement('span');
label.className = 'text-sm font-medium text-gray-700';
label.textContent = config.label;
controlRow.appendChild(label);
const swatchesContainer = document.createElement('div');
swatchesContainer.className = 'flex items-center space-x-2';
swatchesContainer.dataset.controlKey = key;
for (const color of Object.values(beletageColors)) {
const swatch = document.createElement('div');
swatch.className = 'color-swatch';
swatch.style.backgroundColor = color;
swatch.dataset.color = color;
if (color === config.default) swatch.classList.add('active');
swatchesContainer.appendChild(swatch);
}
controlRow.appendChild(swatchesContainer);
controls.colorControls.appendChild(controlRow);
}
controls.colorControls.addEventListener('click', (e) => {
if (e.target.classList.contains('color-swatch')) {
const key = e.target.parentElement.dataset.controlKey;
currentColors[key] = e.target.dataset.color;
e.target.parentElement.querySelectorAll('.color-swatch').forEach(s => s.classList.remove('active'));
e.target.classList.add('active');
updatePreview();
}
});
}
function generateSignatureHTML(data) {
const {
prefix, name, title, phone, phoneLink, greenLineWidth, gutterWidth,
iconTextSpacing, iconVerticalOffset, mottoSpacing, sectionSpacing, titleSpacing, logoSpacing,
isReply, isSuperReply, colors, images
} = data;
const hideTitle = isReply || isSuperReply ? 'mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;' : '';
const hideLogoAddress = isReply || isSuperReply ? 'mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;' : '';
const hideBottom = isSuperReply ? 'mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;' : '';
const hidePhoneIcon = isSuperReply ? 'mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;' : '';
const spacerWidth = Math.max(0, iconTextSpacing);
const textPaddingLeft = Math.max(0, -iconTextSpacing);
const prefixHTML = prefix ? `<span style="font-size:13px; color:${colors.prefix};">${prefix} </span>` : '';
const logoWidth = controls.useSvgCheckbox.checked ? 162 : 162;
const logoHeight = controls.useSvgCheckbox.checked ? 24 : 24;
return `
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540" style="font-family: Arial, Helvetica, sans-serif; color:#333333; font-size:14px; line-height:18px;">
<tbody>
<tr><td style="padding:0 0 ${titleSpacing}px 0;">${prefixHTML}<span style="font-size:15px; color:${colors.name}; font-weight:700;">${name}</span></td></tr>
<tr style="${hideTitle}"><td style="padding:0 0 8px 0;"><span style="font-size:12px; color:${colors.title};">${title}</span></td></tr>
<tr style="${hideBottom}">
<td style="padding:0; font-size:0; line-height:0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540">
<tr>
<td width="${greenLineWidth}" height="2" bgcolor="${beletageColors.verde}" style="font-size:0; line-height:0; height:2px;"></td>
<td width="${540 - greenLineWidth}" height="2" style="font-size:0; line-height:0; height:2px;"></td>
</tr>
</table>
</td>
</tr>
<tr style="${hideLogoAddress}"><td style="padding:${logoSpacing}px 0 ${parseInt(logoSpacing, 10) + 2}px 0;">
<a href="https://www.beletage.ro" style="text-decoration:none; border:0;">
<img src="${images.logo}" alt="Beletage" style="display:block; border:0; height:${logoHeight}px; width:${logoWidth}px;" height="${logoHeight}" width="${logoWidth}">
</a>
</td></tr>
<tr>
<td style="padding-top:${hideLogoAddress ? '0' : sectionSpacing}px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540" style="font-size:13px; line-height:18px;">
<tbody>
<tr style="${hideLogoAddress}">
<td width="${gutterWidth}" style="width:${gutterWidth}px; font-size:0; line-height:0;"></td>
<td width="11" style="width:11px; vertical-align:top; padding-top:${4 + iconVerticalOffset}px;">
<img src="${images.greySlash}" alt="" width="11" height="11" style="display: block; border:0;">
</td>
<td width="${spacerWidth}" style="width:${spacerWidth}px; font-size:0; line-height:0;"></td>
<td style="vertical-align:top; padding:0 0 0 ${textPaddingLeft}px;">
<a href="https://maps.google.com/?q=str.%20Unirii%203%2C%20ap.%2026%2C%20Cluj-Napoca%20400417%2C%20Rom%C3%A2nia" style="color:${colors.address}; text-decoration:none;"><span style="color:${colors.address}; text-decoration:none;">str. Unirii, nr. 3, ap. 26<br>Cluj-Napoca, Cluj 400417<br>România</span></a>
</td>
</tr>
<tr>
<td width="${gutterWidth}" style="width:${gutterWidth}px; font-size:0; line-height:0;"></td>
<td width="11" style="width:11px; vertical-align:top; padding-top:${12 + iconVerticalOffset}px; ${hidePhoneIcon}">
<img src="${images.greenSlash}" alt="" width="11" height="7" style="display: block; border:0;">
</td>
<td width="${isSuperReply ? 0 : spacerWidth}" style="width:${isSuperReply ? 0 : spacerWidth}px; font-size:0; line-height:0;"></td>
<td style="vertical-align:top; padding:8px 0 0 ${isSuperReply ? 0 : textPaddingLeft}px;">
<a href="${phoneLink}" style="color:${colors.phone}; text-decoration:none;"><span style="color:${colors.phone}; text-decoration:none;">${phone}</span></a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr style="${hideBottom}"><td style="padding:${sectionSpacing}px 0 ${mottoSpacing}px 0;"><a href="https://www.beletage.ro" style="color:${colors.website}; text-decoration:none;"><span style="color:${colors.website}; text-decoration:none;">www.beletage.ro</span></a></td></tr>
<tr style="${hideBottom}">
<td style="padding:0; font-size:0; line-height:0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540">
<tr>
<td width="${greenLineWidth}" height="1" bgcolor="${beletageColors.verde}" style="font-size:0; line-height:0; height:1px;"></td>
<td width="${540 - greenLineWidth}" height="1" style="font-size:0; line-height:0; height:1px;"></td>
</tr>
</table>
</td>
</tr>
<tr style="${hideBottom}"><td style="padding:${mottoSpacing}px 0 0 0;"><span style="font-size:12px; color:${colors.motto}; font-style:italic;">we make complex simple</span></td></tr>
</tbody>
</table>
`;
}
function updatePreview() {
const phoneRaw = controls.phone.value.replace(/\s/g, '');
let formattedPhone = controls.phone.value;
let phoneLink = `tel:${phoneRaw}`;
if (phoneRaw.length === 10 && phoneRaw.startsWith('07')) {
formattedPhone = `+40 ${phoneRaw.substring(1, 4)} ${phoneRaw.substring(4, 7)} ${phoneRaw.substring(7, 10)}`;
phoneLink = `tel:+40${phoneRaw.substring(1)}`;
}
if (controls.superReplyCheckbox.checked) {
controls.replyCheckbox.checked = true;
controls.replyCheckbox.disabled = true;
} else {
controls.replyCheckbox.disabled = false;
}
const data = {
prefix: controls.prefix.value,
name: controls.name.value,
title: controls.title.value,
phone: formattedPhone,
phoneLink: phoneLink,
greenLineWidth: controls.greenLine.value,
gutterWidth: controls.gutter.value,
iconTextSpacing: controls.iconTextSpacing.value,
iconVerticalOffset: parseInt(controls.iconVertical.value, 10),
mottoSpacing: controls.mottoSpacing.value,
sectionSpacing: controls.sectionSpacing.value,
titleSpacing: controls.titleSpacing.value,
logoSpacing: controls.logoSpacing.value,
isReply: controls.replyCheckbox.checked,
isSuperReply: controls.superReplyCheckbox.checked,
colors: { ...currentColors },
images: controls.useSvgCheckbox.checked ? imageSets.svg : imageSets.png
};
values.greenLine.textContent = data.greenLineWidth;
values.gutter.textContent = data.gutterWidth;
values.iconTextSpacing.textContent = data.iconTextSpacing;
values.iconVertical.textContent = data.iconVerticalOffset;
values.mottoSpacing.textContent = data.mottoSpacing;
values.sectionSpacing.textContent = data.sectionSpacing;
values.titleSpacing.textContent = data.titleSpacing;
values.logoSpacing.textContent = data.logoSpacing;
previewContainer.innerHTML = generateSignatureHTML(data);
}
// --- Inițializare ---
createColorPickers();
Object.values(controls).forEach(control => {
if (control.id !== 'export-btn' && control.id !== 'zoom-btn') {
control.addEventListener('input', updatePreview);
}
});
document.querySelectorAll('.collapsible-trigger').forEach(trigger => {
trigger.addEventListener('click', () => {
const content = trigger.nextElementSibling;
trigger.classList.toggle('open');
content.classList.toggle('open');
});
});
controls.zoomBtn.addEventListener('click', () => {
const isZoomed = previewWrapper.style.transform === 'scale(2)';
if (isZoomed) {
previewWrapper.style.transform = 'scale(1)';
controls.zoomBtn.textContent = 'Zoom 200%';
} else {
previewWrapper.style.transform = 'scale(2)';
controls.zoomBtn.textContent = 'Zoom 100%';
}
});
controls.exportBtn.addEventListener('click', () => {
const finalHTML = previewContainer.innerHTML;
const blob = new Blob([finalHTML], { type: 'text/html' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'semnatura-beletage.html';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
updatePreview();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,148 @@
Pauza de masa
Timp personal
Concediu
Compensare overtime
Beletage
Ofertare
Configurari
Organizare initiala
Pregatire Portofoliu
Website
Documentare
Design grafic
Design interior
Design exterior
Releveu
Reclama
000 Farmacie
002 Cladire birouri Stratec
003 PUZ Bellavista
007 Design Apartament Teodora
010 Casa Doinei
016 Duplex Eremia
024 Bloc Petofi
028 PUZ Borhanci-Sopor
033 Mansardare Branului
039 Cabinete Stoma Scala
041 Imobil mixt Progresului
045 Casa Andrei Muresanu
052 PUZ Carpenului
059 PUZ Nordului
064 Casa Salicea
066 Terasa Gherase
070 Bloc Fanatelor
073 Case Frumoasa
074 PUG Cosbuc
076 Casa Copernicus
077 PUZ Schimbare destinatie Brancusi
078 Service auto Linistei
079 Amenajare drum Servitute Eremia
080 Bloc Tribunul
081 Extindere casa Gherase
083 Modificari casa Zsigmund 18
084 Mansardare Petofi 21
085 Container CT Spital Tabacarilor
086 Imprejmuire casa sat Gheorgheni
087 Duplex Oasului fn
089 PUZ A-Liu Sopor
090 VR MedEvents
091 Reclama Caparol
092 Imobil birouri 13 Septembrie
093 Casa Salistea Noua
094 PUD Casa Rediu
095 Duplex Vanatorului
096 Design apartament Sopor
097 Cabana Gilau
101 PUZ Gilau
102 PUZ Ghimbav
103 Piscine Lunca Noua
104 PUZ REGHIN
105 CUT&Crust
106 PUZ Mihai Romanu Nord
108 Reabilitare Bloc Beiusului
109 Case Samboleni
110 Penny Crasna
111 Anexa Piscina Borhanci
112 PUZ Blocuri Bistrita
113 PUZ VARATEC-FIRIZA
114 PUG Husi
115 PUG Josenii Bargaului
116 PUG Monor
117 Schimbare Destinatie Mihai Viteazu 2
120 Anexa Brasov
121 Imprejurare imobil Mesterul Manole 9
122 Fastfood Bashar
123 PUD Rediu 2
127 Casa Socaciu Ciurila
128 Schimbare de destinatie Danubius
129 (re) Casa Sarca-Sorescu
130 Casa Suta-Wonderland
131 PUD Oasului Hufi
132 Reabilitare Camin Cultural Baciu
133 PUG Feldru
134 DALI Blocuri Murfatlar
135 Case de vacanta Dianei
136 PUG BROSTENI
139 Casa Turda
140 Releveu Bistrita (Morariu)
141 PUZ Janovic Jeno
142 Penny Borhanci
143 Pavilion Politie Radauti
149 Duplex Sorescu 31-33
150 DALI SF Scoala Baciu
151 Casa Alexandru Bohatiel 17
152 PUZ Penny Tautii Magheraus
153 PUG Banita
155 PT Scoala Floresti
156 Case Sorescu
157 Gradi-Cresa Baciu
158 Duplex Sorescu 21-23
159 Amenajare Spatiu Grenke PBC
160 Etajare Primaria Baciu
161 Extindere Ap Baciu
164 SD salon Aurel Vlaicu
165 Reclama Marasti
166 Catei Apahida
167 Apartament Mircea Zaciu 13-15
169 Casa PETRILA 37
170 Cabana Campeni AB
171 Camin Apahida
L089 PUZ TUSA-BOJAN
172 Design casa Iugoslaviei 18
173 Reabilitare spitale Sighetu
174 StudX UMFST
176 - 2025 - ReAC Ansamblu rezi Bibescu
CU
Schita
Avize
PUD
AO
PUZ
PUG
DTAD
DTAC
PT
Detalii de Executie
Studii de fundamentare
Regulament
Parte desenata
Parte scrisa
Consultanta client
Macheta
Consultanta receptie
Redactare
Depunere
Ridicare
Verificare proiect
Vizita santier
Master MATDR

View File

@@ -0,0 +1,694 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<title>Beletage Word XML Data Engine</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- JSZip pentru arhivă ZIP -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"
integrity="sha512-FGv7V3GpCr3C6wz6Q4z8F1v8y4mZohwPqhwKiPfz0btvAvOE0tfLOgvBcFQncn1C3KW0y5fN9c7v1sQW8vGfMQ=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<style>
:root {
color-scheme: dark;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
margin: 0;
padding: 1.5rem;
background: #020617;
color: #e5e7eb;
}
h1 {
font-size: 1.7rem;
margin-bottom: .25rem;
}
.subtitle {
font-size: .9rem;
color: #9ca3af;
margin-bottom: 1rem;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.card {
background: #020617;
border-radius: 1rem;
padding: 1.1rem 1.3rem;
margin-bottom: 1rem;
border: 1px solid #1e293b;
box-shadow: 0 15px 35px rgba(0,0,0,.45);
}
label {
font-size: .8rem;
color: #9ca3af;
display: block;
margin-bottom: .2rem;
}
input, textarea, select {
width: 100%;
box-sizing: border-box;
padding: .5rem .6rem;
border-radius: .5rem;
border: 1px solid #334155;
background: #020617;
color: #e5e7eb;
font-family: inherit;
font-size: .9rem;
outline: none;
}
input:focus, textarea:focus, select:focus {
border-color: #38bdf8;
box-shadow: 0 0 0 1px #38bdf8;
}
textarea { min-height: 140px; resize: vertical; }
.row {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.col-3 { flex: 1 1 220px; }
.col-6 { flex: 1 1 320px; }
.col-9 { flex: 3 1 420px; }
button {
padding: .55rem 1.1rem;
border-radius: 999px;
border: none;
background: linear-gradient(135deg, #38bdf8, #6366f1);
color: #fff;
font-size: .9rem;
font-weight: 600;
cursor: pointer;
box-shadow: 0 12px 25px rgba(37,99,235,.4);
}
button:hover { filter: brightness(1.05); transform: translateY(-1px); }
button:active { transform: translateY(0); box-shadow: 0 8px 18px rgba(37,99,235,.6); }
.btn-secondary {
background: transparent;
border: 1px solid #4b5563;
box-shadow: none;
}
.btn-secondary:hover {
background: #020617;
box-shadow: 0 8px 20px rgba(0,0,0,.6);
}
.btn-small {
font-size: .8rem;
padding: .35rem .8rem;
box-shadow: none;
}
.toggle {
display: inline-flex;
align-items: center;
gap: .4rem;
font-size: .8rem;
color: #cbd5f5;
cursor: pointer;
user-select: none;
}
.toggle input { width: auto; }
.pill-row {
display: flex;
flex-wrap: wrap;
gap: .4rem;
margin-bottom: .4rem;
}
.pill {
padding: .25rem .7rem;
border-radius: 999px;
border: 1px solid #334155;
font-size: .8rem;
cursor: pointer;
background: #020617;
color: #e5e7eb;
display: inline-flex;
align-items: center;
gap: .35rem;
}
.pill.active {
background: linear-gradient(135deg, #38bdf8, #6366f1);
border-color: transparent;
color: #0f172a;
}
.pill span.remove {
font-size: .8rem;
opacity: .7;
}
.pill span.remove:hover { opacity: 1; }
.small {
font-size: .8rem;
color: #9ca3af;
margin-top: .25rem;
}
pre {
background: #020617;
border-radius: .75rem;
padding: .7rem .8rem;
border: 1px solid #1f2937;
overflow: auto;
font-size: .8rem;
max-height: 340px;
}
.badge {
display: inline-flex;
align-items: center;
padding: .15rem .45rem;
border-radius: 999px;
font-size: .7rem;
background: rgba(148,163,184,.18);
margin-right: .4rem;
margin-bottom: .25rem;
}
@media (max-width: 768px) {
body { padding: 1rem; }
.card { padding: 1rem; }
}
</style>
</head>
<body>
<div class="container">
<h1>Beletage Word XML Data Engine</h1>
<p class="subtitle">
Generator de <strong>Custom XML Parts</strong> pentru Word, pe categorii (Beneficiar, Proiect, Suprafete, Meta etc.),
cu mod <em>Simple</em> / <em>Advanced</em> și câmpuri derivate (Short, Upper, Initials) + POT/CUT pregătite.
</p>
<!-- SETĂRI GLOBALE -->
<div class="card">
<div class="row">
<div class="col-6">
<label for="baseNs">Bază Namespace (se completează automat cu /Categorie)</label>
<input id="baseNs" type="text" value="http://schemas.beletage.ro/contract">
<div class="small">Ex: <code>http://schemas.beletage.ro/contract</code> → pentru categoria „Proiect” devine
<code>http://schemas.beletage.ro/contract/Proiect</code>.
</div>
</div>
<div class="col-3">
<label>Mod generare câmpuri</label>
<div class="pill-row">
<div class="pill active" id="modeSimplePill" onclick="setMode('simple')">Simple</div>
<div class="pill" id="modeAdvancedPill" onclick="setMode('advanced')">Advanced</div>
</div>
<div class="small">
<strong>Simple</strong>: doar câmpurile tale.<br>
<strong>Advanced</strong>: + Short / Upper / Lower / Initials / First pentru fiecare câmp.
</div>
</div>
<div class="col-3">
<label>Opțiuni extra</label>
<div class="small" style="margin-top:.25rem;">
<label class="toggle">
<input type="checkbox" id="computeMetrics" checked>
<span>Adaugă câmpuri POT / CUT în categoria Suprafete</span>
</label>
</div>
</div>
</div>
</div>
<!-- CATEGORII -->
<div class="card">
<div class="row">
<div class="col-3">
<label>Categorii de date</label>
<div id="categoryPills" class="pill-row"></div>
<button class="btn-secondary btn-small" onclick="addCategoryPrompt()">+ Adaugă categorie</button>
<div class="small">
Exemple de organizare: <code>Beneficiar</code>, <code>Proiect</code>, <code>Suprafete</code>, <code>Meta</code>.
</div>
</div>
<div class="col-9">
<label>Câmpuri pentru categoria selectată</label>
<textarea id="fieldsArea"></textarea>
<div class="small">
Un câmp pe linie. Poți edita lista. Butonul „Reset categorie la preset” reîncarcă valorile default pentru
categoria curentă (dacă există).
</div>
<div style="margin-top:.5rem; display:flex; gap:.5rem; flex-wrap:wrap;">
<button class="btn-secondary btn-small" onclick="resetCategoryToPreset()">Reset categorie la preset</button>
<button class="btn-secondary btn-small" onclick="clearCategoryFields()">Curăță câmpurile</button>
</div>
<div class="small" id="nsRootInfo" style="margin-top:.6rem;"></div>
</div>
</div>
</div>
<!-- GENERARE & DOWNLOAD -->
<div class="card">
<div style="display:flex; flex-wrap:wrap; gap:.5rem; align-items:center; margin-bottom:.5rem;">
<button onclick="generateAll()">Generează XML pentru toate categoriile</button>
<button class="btn-secondary" onclick="downloadCurrentXml()">Descarcă XML categorie curentă</button>
<button class="btn-secondary" onclick="downloadZipAll()">Descarcă ZIP cu toate XML-urile</button>
</div>
<div class="small">
<span class="badge">Tip</span>
În Word, fiecare fișier generat devine un Custom XML Part separat (ex: <code>BeneficiarData.xml</code>,
<code>ProiectData.xml</code> etc.), perfect pentru organizarea mapping-urilor.
</div>
</div>
<!-- PREVIEW -->
<div class="card">
<h3 style="margin-top:0;">Preview XML & XPaths</h3>
<div class="small" style="margin-bottom:.4rem;">
Selectează o categorie pentru a vedea XML-ul și XPaths-urile aferente.
</div>
<div class="row">
<div class="col-6">
<div class="badge">XML categorie curentă</div>
<pre id="xmlPreview"></pre>
</div>
<div class="col-6">
<div class="badge">XPaths categorie curentă</div>
<pre id="xpathPreview"></pre>
</div>
</div>
</div>
</div>
<script>
// --- PRESETURI CATEGORII ---
const defaultPresets = {
"Beneficiar": [
"NumeClient",
"Adresa",
"CUI",
"CNP",
"Reprezentant",
"Email",
"Telefon"
],
"Proiect": [
"TitluProiect",
"AdresaImobil",
"NrCadastral",
"NrCF",
"Localitate",
"Judet"
],
"Suprafete": [
"SuprafataTeren",
"SuprafataConstruitaLaSol",
"SuprafataDesfasurata",
"SuprafataUtila"
],
"Meta": [
"NrContract",
"DataContract",
"Responsabil",
"VersiuneDocument",
"DataGenerarii"
]
};
// --- STATE ---
let categories = {}; // { Categorie: { fieldsText: "..." } }
let currentCategory = "Beneficiar";
let mode = "advanced"; // "simple" | "advanced"
const xmlParts = {}; // { Categorie: xmlString }
const xpathParts = {}; // { Categorie: xpathString }
// --- UTILITARE ---
function sanitizeName(name) {
if (!name) return null;
let n = name.trim();
if (!n) return null;
n = n.replace(/\s+/g, "_").replace(/[^A-Za-z0-9_.-]/g, "");
if (!/^[A-Za-z_]/.test(n)) n = "_" + n;
return n;
}
function initialsFromLabel(label) {
if (!label) return "";
return label.trim().split(/\s+/).map(s => s.charAt(0).toUpperCase() + ".").join("");
}
function firstToken(label) {
if (!label) return "";
return label.trim().split(/\s+/)[0] || "";
}
function getBaseNamespace() {
const val = document.getElementById("baseNs").value.trim();
return val || "http://schemas.beletage.ro/contract";
}
function getCategoryNamespace(cat) {
const base = getBaseNamespace();
const safeCat = sanitizeName(cat) || cat;
return base.replace(/\/+$/,"") + "/" + safeCat;
}
function getCategoryRoot(cat) {
const safeCat = sanitizeName(cat) || cat;
return safeCat + "Data";
}
// --- MOD SIMPLE/ADVANCED ---
function setMode(m) {
mode = m === "advanced" ? "advanced" : "simple";
document.getElementById("modeSimplePill").classList.toggle("active", mode === "simple");
document.getElementById("modeAdvancedPill").classList.toggle("active", mode === "advanced");
// regenerăm previw dacă avem ceva
generateAll(false);
}
// --- CATEGORII: INIT, UI, STORAGE ---
function initCategories() {
// încarcă din localStorage, altfel default
const saved = window.localStorage.getItem("beletage_xml_categories");
if (saved) {
try {
const parsed = JSON.parse(saved);
categories = parsed.categories || {};
currentCategory = parsed.currentCategory || "Beneficiar";
} catch(e) {
Object.keys(defaultPresets).forEach(cat => {
categories[cat] = { fieldsText: defaultPresets[cat].join("\n") };
});
currentCategory = "Beneficiar";
}
} else {
Object.keys(defaultPresets).forEach(cat => {
categories[cat] = { fieldsText: defaultPresets[cat].join("\n") };
});
currentCategory = "Beneficiar";
}
renderCategoryPills();
loadCategoryToUI(currentCategory);
}
function persistCategories() {
try {
window.localStorage.setItem("beletage_xml_categories", JSON.stringify({
categories,
currentCategory
}));
} catch(e){}
}
function renderCategoryPills() {
const container = document.getElementById("categoryPills");
container.innerHTML = "";
Object.keys(categories).forEach(cat => {
const pill = document.createElement("div");
pill.className = "pill" + (cat === currentCategory ? " active" : "");
pill.onclick = () => switchCategory(cat);
pill.textContent = cat;
// nu permitem ștergerea preset-urilor de bază direct (doar la custom)
if (!defaultPresets[cat]) {
const remove = document.createElement("span");
remove.className = "remove";
remove.textContent = "×";
remove.onclick = (ev) => {
ev.stopPropagation();
deleteCategory(cat);
};
pill.appendChild(remove);
}
container.appendChild(pill);
});
}
function switchCategory(cat) {
saveCurrentCategoryFields();
currentCategory = cat;
renderCategoryPills();
loadCategoryToUI(cat);
updateNsRootInfo();
showPreview(cat);
persistCategories();
}
function loadCategoryToUI(cat) {
const area = document.getElementById("fieldsArea");
area.value = categories[cat]?.fieldsText || "";
updateNsRootInfo();
}
function saveCurrentCategoryFields() {
const area = document.getElementById("fieldsArea");
if (!categories[currentCategory]) {
categories[currentCategory] = { fieldsText: "" };
}
categories[currentCategory].fieldsText = area.value;
}
function deleteCategory(cat) {
if (!confirm(`Sigur ștergi categoria "${cat}"?`)) return;
delete categories[cat];
const keys = Object.keys(categories);
currentCategory = keys[0] || "Beneficiar";
renderCategoryPills();
loadCategoryToUI(currentCategory);
updateNsRootInfo();
persistCategories();
}
function addCategoryPrompt() {
const name = prompt("Nume categorie nouă (ex: Urbanism, Fiscal, Altele):");
if (!name) return;
const trimmed = name.trim();
if (!trimmed) return;
if (categories[trimmed]) {
alert("Categoria există deja.");
return;
}
categories[trimmed] = { fieldsText: "" };
currentCategory = trimmed;
renderCategoryPills();
loadCategoryToUI(currentCategory);
updateNsRootInfo();
persistCategories();
}
function resetCategoryToPreset() {
if (!defaultPresets[currentCategory]) {
alert("Categoria curentă nu are preset definit.");
return;
}
if (!confirm("Resetezi lista de câmpuri la presetul standard pentru această categorie?")) return;
categories[currentCategory].fieldsText = defaultPresets[currentCategory].join("\n");
loadCategoryToUI(currentCategory);
persistCategories();
}
function clearCategoryFields() {
categories[currentCategory].fieldsText = "";
loadCategoryToUI(currentCategory);
persistCategories();
}
function updateNsRootInfo() {
const ns = getCategoryNamespace(currentCategory);
const root = getCategoryRoot(currentCategory);
document.getElementById("nsRootInfo").innerHTML =
`<strong>Namespace:</strong> <code>${ns}</code><br>` +
`<strong>Root element:</strong> <code>&lt;${root}&gt;</code>`;
}
// --- GENERARE XML PENTRU O CATEGORIE ---
function generateCategory(cat) {
const entry = categories[cat];
if (!entry) return { xml: "", xpaths: "" };
const raw = (entry.fieldsText || "").split(/\r?\n/)
.map(l => l.trim())
.filter(l => l.length > 0);
if (raw.length === 0) {
return { xml: "", xpaths: "" };
}
const ns = getCategoryNamespace(cat);
const root = getCategoryRoot(cat);
const computeMetrics = document.getElementById("computeMetrics").checked;
const usedNames = new Set();
const fields = []; // { label, baseName, variants: [] }
for (const label of raw) {
const base = sanitizeName(label);
if (!base) continue;
let baseName = base;
let idx = 2;
while (usedNames.has(baseName)) {
baseName = base + "_" + idx;
idx++;
}
usedNames.add(baseName);
const variants = [baseName];
if (mode === "advanced") {
const advCandidates = [
baseName + "Short",
baseName + "Upper",
baseName + "Lower",
baseName + "Initials",
baseName + "First"
];
for (let v of advCandidates) {
let vn = v;
let k = 2;
while (usedNames.has(vn)) {
vn = v + "_" + k;
k++;
}
usedNames.add(vn);
variants.push(vn);
}
}
fields.push({ label, baseName, variants });
}
// detectăm câmpuri pentru metrici (în special categoria Suprafete)
const extraMetricFields = [];
if (computeMetrics && cat.toLowerCase().includes("suprafete")) {
const hasTeren = fields.some(f => f.baseName.toLowerCase().includes("suprafatateren"));
const hasLaSol = fields.some(f => f.baseName.toLowerCase().includes("suprafataconstruitalasol"));
const hasDesf = fields.some(f => f.baseName.toLowerCase().includes("suprafatadesfasurata"));
if (hasTeren && hasLaSol) {
if (!usedNames.has("POT")) {
usedNames.add("POT");
extraMetricFields.push({ label: "Procent Ocupare Teren", baseName: "POT", variants: ["POT"] });
}
}
if (hasTeren && hasDesf) {
if (!usedNames.has("CUT")) {
usedNames.add("CUT");
extraMetricFields.push({ label: "Coeficient Utilizare Teren", baseName: "CUT", variants: ["CUT"] });
}
}
}
// generăm XML
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += `<${root} xmlns="${ns}">\n`;
const allFieldEntries = fields.concat(extraMetricFields);
for (const f of allFieldEntries) {
for (const v of f.variants) {
xml += ` <${v}></${v}>\n`;
}
}
xml += `</${root}>\n`;
// generăm XPaths
let xp = `Categorie: ${cat}\nNamespace: ${ns}\nRoot: /${root}\n\n`;
for (const f of fields) {
xp += `# ${f.label}\n`;
for (const v of f.variants) {
xp += `/${root}/${v}\n`;
}
xp += `\n`;
}
if (extraMetricFields.length > 0) {
xp += `# Metrici auto (POT / CUT)\n`;
for (const f of extraMetricFields) {
for (const v of f.variants) {
xp += `/${root}/${v}\n`;
}
}
xp += `\n`;
}
return { xml, xpaths: xp };
}
// --- GENERARE PENTRU TOATE CATEGORIILE ---
function generateAll(showForCurrent = true) {
saveCurrentCategoryFields();
Object.keys(categories).forEach(cat => {
const { xml, xpaths } = generateCategory(cat);
xmlParts[cat] = xml;
xpathParts[cat] = xpaths;
});
if (showForCurrent) {
showPreview(currentCategory);
}
persistCategories();
}
// --- PREVIEW ---
function showPreview(cat) {
document.getElementById("xmlPreview").textContent = xmlParts[cat] || "<!-- Niciun XML generat încă pentru această categorie. -->";
document.getElementById("xpathPreview").textContent = xpathParts[cat] || "";
}
// --- DOWNLOAD: XML CATEGORIE ---
function downloadCurrentXml() {
generateAll(false);
const xml = xmlParts[currentCategory];
if (!xml) {
alert("Nu există XML generat pentru categoria curentă. Apasă întâi „Generează XML pentru toate categoriile”.");
return;
}
const root = getCategoryRoot(currentCategory);
const fileName = root + ".xml";
const blob = new Blob([xml], { type: "application/xml" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = fileName;
a.click();
URL.revokeObjectURL(a.href);
}
// --- DOWNLOAD: ZIP CU TOATE XML-URILE ---
async function downloadZipAll() {
generateAll(false);
const cats = Object.keys(categories);
if (cats.length === 0) {
alert("Nu există categorii.");
return;
}
const zip = new JSZip();
const folder = zip.folder("customXmlParts");
let hasAny = false;
for (const cat of cats) {
const xml = xmlParts[cat];
if (!xml) continue;
hasAny = true;
const root = getCategoryRoot(cat);
const fileName = root + ".xml";
folder.file(fileName, xml);
}
if (!hasAny) {
alert("Nu există XML generat încă. Apasă întâi „Generează XML pentru toate categoriile”.");
return;
}
const content = await zip.generateAsync({ type: "blob" });
const a = document.createElement("a");
a.href = URL.createObjectURL(content);
a.download = "beletage_custom_xml_parts.zip";
a.click();
URL.revokeObjectURL(a.href);
}
// --- INIT ---
window.addEventListener("DOMContentLoaded", () => {
initCategories();
updateNsRootInfo();
generateAll();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,151 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<title>Generator XML Word Versiune Extinsă</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: system-ui, sans-serif;
padding: 1.5rem;
background: #0f172a;
color: #e5e7eb;
}
.card {
background: #020617;
border-radius: 1rem;
padding: 1.25rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
border: 1px solid #1e293b;
margin-bottom: 1rem;
}
label { font-size: .85rem; color: #94a3b8; }
input, textarea {
width: 100%; padding: .55rem .7rem;
border-radius: .5rem; border: 1px solid #334155;
background: #020617; color: #e5e7eb;
}
textarea { min-height: 120px; }
button {
padding: .6rem 1.2rem; border-radius: 999px; border: none;
background: linear-gradient(135deg,#38bdf8,#6366f1);
font-weight: 600; color: white; cursor: pointer;
}
pre {
background: #000; padding: .8rem; border-radius: .7rem;
border: 1px solid #1e293b; max-height: 350px; overflow: auto;
font-size: .85rem;
}
</style>
</head>
<body>
<h1>Generator Word XML Varianta Extinsă (cu Short / Upper / Lower / Initials)</h1>
<div class="card">
<label>Namespace URI</label>
<input id="nsUri" value="http://schemas.beletage.ro/word/contract">
<label style="margin-top:1rem;">Element rădăcină</label>
<input id="rootElement" value="ContractData">
<label style="margin-top:1rem;">Lista de câmpuri (unul pe linie)</label>
<textarea id="fieldList">NumeClient
TitluProiect
Adresa</textarea>
<button onclick="generateXML()" style="margin-top:1rem;">Generează XML complet</button>
</div>
<div class="card">
<h3>Custom XML Part (item1.xml)</h3>
<pre id="xmlOutput"></pre>
<button onclick="downloadXML()">Descarcă XML</button>
</div>
<div class="card">
<h3>XPaths pentru mapping</h3>
<pre id="xpathOutput"></pre>
</div>
<script>
function sanitize(name) {
if (!name) return null;
let n = name.trim();
if (!n) return null;
n = n.replace(/\s+/g,"_").replace(/[^A-Za-z0-9_.-]/g,"");
if (!/^[A-Za-z_]/.test(n)) n = "_" + n;
return n;
}
function initials(str) {
return str.split(/\s+/).map(s => s[0]?.toUpperCase() + ".").join("");
}
function generateXML() {
const ns = document.getElementById("nsUri").value.trim();
const root = sanitize(document.getElementById("rootElement").value) || "Root";
const fieldRaw = document.getElementById("fieldList").value;
const lines = fieldRaw.split(/\r?\n/)
.map(l => l.trim()).filter(l => l.length);
const fields = [];
for (let l of lines) {
const base = sanitize(l);
if (!base) continue;
fields.push({
base,
variants: [
base, // original
base + "Short", // prescurtat
base + "Upper", // caps
base + "Lower", // lowercase
base + "Initials", // inițiale
base + "First" // primul cuvânt
]
});
}
// === GENERĂM XML ===
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += `<${root} xmlns="${ns}">\n`;
for (const f of fields) {
for (const v of f.variants) {
xml += ` <${v}></${v}>\n`;
}
}
xml += `</${root}>`;
document.getElementById("xmlOutput").textContent = xml;
// === GENERĂM XPATHS ===
let xp = `Namespace: ${ns}\nRoot: /${root}\n\n`;
for (const f of fields) {
xp += `# ${f.base}\n`;
xp += `/${root}/${f.base}\n`;
xp += `/${root}/${f.base}Short\n`;
xp += `/${root}/${f.base}Upper\n`;
xp += `/${root}/${f.base}Lower\n`;
xp += `/${root}/${f.base}Initials\n`;
xp += `/${root}/${f.base}First\n\n`;
}
document.getElementById("xpathOutput").textContent = xp;
}
function downloadXML() {
const text = document.getElementById("xmlOutput").textContent;
const blob = new Blob([text], { type: "application/xml" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "item1.xml";
a.click();
}
</script>
</body>
</html>

View File

@@ -0,0 +1,330 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<title>Generator Word XML Custom Part</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
margin: 0;
padding: 1.5rem;
background: #0f172a;
color: #e5e7eb;
}
h1 {
font-size: 1.6rem;
margin-bottom: 0.5rem;
}
.container {
max-width: 1100px;
margin: 0 auto;
}
.card {
background: #020617;
border-radius: 1rem;
padding: 1.25rem 1.5rem;
box-shadow: 0 15px 40px rgba(0,0,0,0.35);
border: 1px solid #1f2937;
margin-bottom: 1rem;
}
label {
display: block;
font-size: 0.85rem;
color: #9ca3af;
margin-bottom: 0.25rem;
}
input, textarea {
width: 100%;
box-sizing: border-box;
padding: 0.5rem 0.6rem;
border-radius: 0.5rem;
border: 1px solid #374151;
background: #020617;
color: #e5e7eb;
font-family: inherit;
font-size: 0.9rem;
outline: none;
}
input:focus, textarea:focus {
border-color: #38bdf8;
box-shadow: 0 0 0 1px #38bdf8;
}
textarea {
min-height: 140px;
resize: vertical;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.col-6 {
flex: 1 1 260px;
}
button {
padding: 0.6rem 1.2rem;
border-radius: 999px;
border: none;
font-size: 0.9rem;
cursor: pointer;
margin-top: 0.75rem;
background: linear-gradient(135deg, #38bdf8, #6366f1);
color: white;
font-weight: 600;
box-shadow: 0 10px 25px rgba(37, 99, 235, 0.4);
}
button:hover {
filter: brightness(1.05);
transform: translateY(-1px);
}
button:active {
transform: translateY(0);
box-shadow: 0 6px 18px rgba(37,99,235,0.6);
}
pre {
background: #020617;
border-radius: 0.75rem;
padding: 0.75rem 1rem;
overflow: auto;
font-size: 0.8rem;
border: 1px solid #1f2937;
max-height: 360px;
}
.subtitle {
font-size: 0.85rem;
color: #9ca3af;
margin-bottom: 1rem;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
border-radius: 999px;
background: rgba(148, 163, 184, 0.2);
margin-right: 0.25rem;
margin-bottom: 0.25rem;
}
.pill span {
opacity: 0.8;
}
.small {
font-size: 0.8rem;
color: #9ca3af;
margin-top: 0.4rem;
}
.btn-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
}
.btn-secondary {
background: transparent;
border: 1px solid #4b5563;
box-shadow: none;
color: #e5e7eb;
}
.btn-secondary:hover {
background: #111827;
box-shadow: 0 8px 18px rgba(0,0,0,0.5);
}
@media (max-width: 640px) {
body {
padding: 1rem;
}
.card {
padding: 1rem;
}
}
</style>
</head>
<body>
<div class="container">
<h1>Generator XML pentru Word Custom XML Part</h1>
<p class="subtitle">
Introdu câmpurile (unul pe linie) și obții XML pentru <strong>Custom XML Part</strong>, plus XPaths pentru mapping în Word.
</p>
<div class="card">
<div class="row">
<div class="col-6">
<label for="nsUri">Namespace URI (obligatoriu)</label>
<input id="nsUri" type="text"
value="http://schemas.beletage.ro/word/data">
<div class="small">
Exemplu: <code>http://schemas.firma-ta.ro/word/contract</code>
</div>
</div>
<div class="col-6">
<label for="rootElement">Nume element rădăcină</label>
<input id="rootElement" type="text" value="Root">
<div class="small">
Exemplu: <code>ContractData</code>, <code>ClientInfo</code> etc.
</div>
</div>
</div>
<div style="margin-top:1rem;">
<label for="fieldList">Lista de câmpuri (unul pe linie)</label>
<textarea id="fieldList" placeholder="Exemplu:
NumeClient
Adresa
DataContract
ValoareTotala"></textarea>
<div class="small">
Numele va fi curățat automat pentru a fi valid ca nume de element XML
(spațiile devin <code>_</code>, caracterele ciudate se elimină).
</div>
</div>
<div class="btn-row">
<button type="button" onclick="generateXML()">Generează XML</button>
<button type="button" class="btn-secondary" onclick="fillDemo()">Exemplu demo</button>
</div>
</div>
<div class="card">
<div class="pill"><strong>1</strong><span>Custom XML Part (item1.xml)</span></div>
<pre id="xmlOutput"></pre>
<div class="btn-row">
<button type="button" class="btn-secondary" onclick="copyToClipboard('xmlOutput')">
Copiază XML
</button>
<button type="button" class="btn-secondary" onclick="downloadXML()">
Descarcă item1.xml
</button>
</div>
</div>
<div class="card">
<div class="pill"><strong>2</strong><span>XPaths pentru mapping în Word</span></div>
<pre id="xpathOutput"></pre>
<button type="button" class="btn-secondary" onclick="copyToClipboard('xpathOutput')">
Copiază XPaths
</button>
<p class="small">
În Word &rarr; <strong>Developer</strong> &rarr; <strong>XML Mapping Pane</strong> &rarr; alegi Custom XML Part-ul
&rarr; pentru fiecare câmp, click dreapta &rarr; <em>Insert Content Control</em> &rarr; tipul dorit.
</p>
</div>
</div>
<script>
function sanitizeXmlName(name) {
if (!name) return null;
let n = name.trim();
if (!n) return null;
// înlocuim spații cu underscore
n = n.replace(/\s+/g, "_");
// eliminăm caractere invalide pentru nume de element XML
n = n.replace(/[^A-Za-z0-9_.-]/g, "");
// numele XML nu are voie să înceapă cu cifră sau punct sau cratimă
if (!/^[A-Za-z_]/.test(n)) {
n = "_" + n;
}
return n || null;
}
function generateXML() {
const nsUri = document.getElementById("nsUri").value.trim();
const root = sanitizeXmlName(document.getElementById("rootElement").value) || "Root";
const fieldRaw = document.getElementById("fieldList").value;
const xmlOutput = document.getElementById("xmlOutput");
const xpathOutput = document.getElementById("xpathOutput");
if (!nsUri) {
alert("Te rog completează Namespace URI.");
return;
}
const lines = fieldRaw.split(/\r?\n/)
.map(l => l.trim())
.filter(l => l.length > 0);
const fields = [];
const used = new Set();
for (let line of lines) {
const clean = sanitizeXmlName(line);
if (!clean) continue;
let finalName = clean;
let idx = 2;
while (used.has(finalName)) {
finalName = clean + "_" + idx;
idx++;
}
used.add(finalName);
fields.push({ original: line, xmlName: finalName });
}
if (fields.length === 0) {
xmlOutput.textContent = "<!-- Niciun câmp valid. Completează lista de câmpuri. -->";
xpathOutput.textContent = "";
return;
}
// Generăm XML-ul pentru Custom XML Part
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += `<${root} xmlns="${nsUri}">\n`;
for (const f of fields) {
xml += ` <${f.xmlName}></${f.xmlName}>\n`;
}
xml += `</${root}>\n`;
xmlOutput.textContent = xml;
// Generăm lista de XPaths
let xpaths = `Namespace: ${nsUri}\nRoot: /${root}\n\nCâmpuri:\n`;
for (const f of fields) {
xpaths += `- ${f.original} => /${root}/${f.xmlName}\n`;
}
xpathOutput.textContent = xpaths;
}
function copyToClipboard(elementId) {
const el = document.getElementById(elementId);
if (!el || !el.textContent) return;
navigator.clipboard.writeText(el.textContent)
.then(() => alert("Copiat în clipboard."))
.catch(() => alert("Nu am reușit să copiez în clipboard."));
}
function downloadXML() {
const xmlText = document.getElementById("xmlOutput").textContent;
if (!xmlText || xmlText.startsWith("<!--")) {
alert("Nu există XML valid de descărcat.");
return;
}
const blob = new Blob([xmlText], { type: "application/xml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "item1.xml";
a.click();
URL.revokeObjectURL(url);
}
function fillDemo() {
document.getElementById("nsUri").value = "http://schemas.beletage.ro/word/contract";
document.getElementById("rootElement").value = "ContractData";
document.getElementById("fieldList").value = [
"NumeClient",
"AdresaClient",
"Proiect",
"DataContract",
"ValoareTotala",
"Moneda",
"TermenExecutie"
].join("\n");
generateXML();
}
</script>
</body>
</html>

153
logo-sdt-dark.svg Executable file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 61 KiB

365
logo-sdt-light.svg Executable file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 98 KiB

22
logo-us-dark.svg Executable file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

21
logo-us-light.svg Executable file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

7
next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: 'standalone',
};
export default nextConfig;

11935
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "architools",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jszip": "^3.10.1",
"lucide-react": "^0.564.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "^3.4.1",
"uuid": "^13.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/jszip": "^3.4.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/uuid": "^10.0.0",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"shadcn": "^3.8.5",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,29 @@
'use client';
import { FeatureGate } from '@/core/feature-flags';
import { useI18n } from '@/core/i18n';
import { AddressBookModule } from '@/modules/address-book';
export default function AddressBookPage() {
const { t } = useI18n();
return (
<FeatureGate flag="module.address-book" fallback={<ModuleDisabled />}>
<div className="mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t('address-book.title')}</h1>
<p className="text-muted-foreground">{t('address-book.description')}</p>
</div>
<AddressBookModule />
</div>
</FeatureGate>
);
}
function ModuleDisabled() {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-muted-foreground">Modul dezactivat</p>
</div>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import { FeatureGate } from '@/core/feature-flags';
import { useI18n } from '@/core/i18n';
import { AiChatModule } from '@/modules/ai-chat';
export default function AiChatPage() {
const { t } = useI18n();
return (
<FeatureGate flag="module.ai-chat" fallback={<ModuleDisabled />}>
<div className="mx-auto max-w-5xl space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t('ai-chat.title')}</h1>
<p className="text-muted-foreground">{t('ai-chat.description')}</p>
</div>
<AiChatModule />
</div>
</FeatureGate>
);
}
function ModuleDisabled() {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-muted-foreground">Modul dezactivat</p>
</div>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import { FeatureGate } from '@/core/feature-flags';
import { useI18n } from '@/core/i18n';
import { DigitalSignaturesModule } from '@/modules/digital-signatures';
export default function DigitalSignaturesPage() {
const { t } = useI18n();
return (
<FeatureGate flag="module.digital-signatures" fallback={<ModuleDisabled />}>
<div className="mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t('digital-signatures.title')}</h1>
<p className="text-muted-foreground">{t('digital-signatures.description')}</p>
</div>
<DigitalSignaturesModule />
</div>
</FeatureGate>
);
}
function ModuleDisabled() {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-muted-foreground">Modul dezactivat</p>
</div>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import { FeatureGate } from '@/core/feature-flags';
import { useI18n } from '@/core/i18n';
import { EmailSignatureModule } from '@/modules/email-signature';
export default function EmailSignaturePage() {
const { t } = useI18n();
return (
<FeatureGate flag="module.email-signature" fallback={<ModuleDisabled />}>
<div className="mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t('email-signature.title')}</h1>
<p className="text-muted-foreground">{t('email-signature.description')}</p>
</div>
<EmailSignatureModule />
</div>
</FeatureGate>
);
}
function ModuleDisabled() {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-muted-foreground">Modul dezactivat</p>
</div>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import { FeatureGate } from '@/core/feature-flags';
import { useI18n } from '@/core/i18n';
import { ItInventoryModule } from '@/modules/it-inventory';
export default function ItInventoryPage() {
const { t } = useI18n();
return (
<FeatureGate flag="module.it-inventory" fallback={<ModuleDisabled />}>
<div className="mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t('it-inventory.title')}</h1>
<p className="text-muted-foreground">{t('it-inventory.description')}</p>
</div>
<ItInventoryModule />
</div>
</FeatureGate>
);
}
function ModuleDisabled() {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-muted-foreground">Modul dezactivat</p>
</div>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import { FeatureGate } from '@/core/feature-flags';
import { useI18n } from '@/core/i18n';
import { MiniUtilitiesModule } from '@/modules/mini-utilities';
export default function MiniUtilitiesPage() {
const { t } = useI18n();
return (
<FeatureGate flag="module.mini-utilities" fallback={<ModuleDisabled />}>
<div className="mx-auto max-w-5xl space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t('mini-utilities.title')}</h1>
<p className="text-muted-foreground">{t('mini-utilities.description')}</p>
</div>
<MiniUtilitiesModule />
</div>
</FeatureGate>
);
}
function ModuleDisabled() {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-muted-foreground">Modul dezactivat</p>
</div>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import { FeatureGate } from '@/core/feature-flags';
import { useI18n } from '@/core/i18n';
import { PasswordVaultModule } from '@/modules/password-vault';
export default function PasswordVaultPage() {
const { t } = useI18n();
return (
<FeatureGate flag="module.password-vault" fallback={<ModuleDisabled />}>
<div className="mx-auto max-w-5xl space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t('password-vault.title')}</h1>
<p className="text-muted-foreground">{t('password-vault.description')}</p>
</div>
<PasswordVaultModule />
</div>
</FeatureGate>
);
}
function ModuleDisabled() {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-muted-foreground">Modul dezactivat</p>
</div>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import { FeatureGate } from '@/core/feature-flags';
import { useI18n } from '@/core/i18n';
import { PromptGeneratorModule } from '@/modules/prompt-generator';
export default function PromptGeneratorPage() {
const { t } = useI18n();
return (
<FeatureGate flag="module.prompt-generator" fallback={<ModuleDisabled />}>
<div className="mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t('prompt-generator.title')}</h1>
<p className="text-muted-foreground">{t('prompt-generator.description')}</p>
</div>
<PromptGeneratorModule />
</div>
</FeatureGate>
);
}
function ModuleDisabled() {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-muted-foreground">Modul dezactivat</p>
</div>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import { FeatureGate } from '@/core/feature-flags';
import { useI18n } from '@/core/i18n';
import { RegistraturaModule } from '@/modules/registratura';
export default function RegistraturaPage() {
const { t } = useI18n();
return (
<FeatureGate flag="module.registratura" fallback={<ModuleDisabled />}>
<div className="mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t('registratura.title')}</h1>
<p className="text-muted-foreground">{t('registratura.description')}</p>
</div>
<RegistraturaModule />
</div>
</FeatureGate>
);
}
function ModuleDisabled() {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-muted-foreground">Modul dezactivat</p>
</div>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import { FeatureGate } from '@/core/feature-flags';
import { useI18n } from '@/core/i18n';
import { TagManagerModule } from '@/modules/tag-manager';
export default function TagManagerPage() {
const { t } = useI18n();
return (
<FeatureGate flag="module.tag-manager" fallback={<ModuleDisabled />}>
<div className="mx-auto max-w-5xl space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t('tag-manager.title')}</h1>
<p className="text-muted-foreground">{t('tag-manager.description')}</p>
</div>
<TagManagerModule />
</div>
</FeatureGate>
);
}
function ModuleDisabled() {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-muted-foreground">Modul dezactivat</p>
</div>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import { FeatureGate } from '@/core/feature-flags';
import { useI18n } from '@/core/i18n';
import { WordTemplatesModule } from '@/modules/word-templates';
export default function WordTemplatesPage() {
const { t } = useI18n();
return (
<FeatureGate flag="module.word-templates" fallback={<ModuleDisabled />}>
<div className="mx-auto max-w-5xl space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t('word-templates.title')}</h1>
<p className="text-muted-foreground">{t('word-templates.description')}</p>
</div>
<WordTemplatesModule />
</div>
</FeatureGate>
);
}
function ModuleDisabled() {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-muted-foreground">Modul dezactivat</p>
</div>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import { FeatureGate } from '@/core/feature-flags';
import { useI18n } from '@/core/i18n';
import { WordXmlModule } from '@/modules/word-xml';
export default function WordXmlPage() {
const { t } = useI18n();
return (
<FeatureGate flag="module.word-xml" fallback={<ModuleDisabled />}>
<div className="mx-auto max-w-5xl space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t('word-xml.title')}</h1>
<p className="text-muted-foreground">{t('word-xml.description')}</p>
</div>
<WordXmlModule />
</div>
</FeatureGate>
);
}
function ModuleDisabled() {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-muted-foreground">Modul dezactivat</p>
</div>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

126
src/app/globals.css Normal file
View File

@@ -0,0 +1,126 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

29
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,29 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Providers } from './providers';
import { AppShell } from '@/shared/components/layout/app-shell';
const inter = Inter({ subsets: ['latin', 'latin-ext'] });
export const metadata: Metadata = {
title: 'ArchiTools',
description: 'Platformă internă de instrumente pentru birou',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ro" suppressHydrationWarning>
<body className={inter.className}>
<Providers>
<AppShell>{children}</AppShell>
</Providers>
</body>
</html>
);
}

18
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,18 @@
import Link from 'next/link';
export default function NotFound() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center">
<h1 className="text-6xl font-bold text-muted-foreground">404</h1>
<p className="mt-4 text-lg text-muted-foreground">
Pagina nu a fost găsită
</p>
<Link
href="/"
className="mt-6 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Înapoi la panou
</Link>
</div>
);
}

134
src/app/page.tsx Normal file
View File

@@ -0,0 +1,134 @@
'use client';
import Link from 'next/link';
import * as Icons from 'lucide-react';
import { getAllModules } from '@/core/module-registry';
import { useFeatureFlag } from '@/core/feature-flags';
import { useI18n } from '@/core/i18n';
import { EXTERNAL_TOOLS } from '@/config/external-tools';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/shared/components/ui/card';
import { Badge } from '@/shared/components/ui/badge';
function DynamicIcon({ name, className }: { name: string; className?: string }) {
const pascalName = name.replace(/(^|-)([a-z])/g, (_, _p, c: string) => c.toUpperCase());
const IconComponent = (Icons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[pascalName];
if (!IconComponent) return <Icons.Circle className={className} />;
return <IconComponent className={className} />;
}
function ModuleCard({ module }: { module: { id: string; name: string; description: string; icon: string; route: string; featureFlag: string } }) {
const enabled = useFeatureFlag(module.featureFlag);
if (!enabled) return null;
return (
<Link href={module.route}>
<Card className="h-full transition-colors hover:border-primary/50 hover:bg-accent/30">
<CardHeader className="flex flex-row items-center gap-4 space-y-0">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-primary/10">
<DynamicIcon name={module.icon} className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-base">{module.name}</CardTitle>
<CardDescription className="text-sm">{module.description}</CardDescription>
</div>
</CardHeader>
</Card>
</Link>
);
}
const CATEGORY_LABELS: Record<string, string> = {
dev: 'Dezvoltare',
tools: 'Instrumente',
monitoring: 'Monitorizare',
security: 'Securitate',
};
export default function DashboardPage() {
const { t } = useI18n();
const modules = getAllModules();
const toolCategories = Object.keys(CATEGORY_LABELS).filter(
(cat) => EXTERNAL_TOOLS.some((tool) => tool.category === cat)
);
return (
<div className="mx-auto max-w-6xl space-y-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">{t('dashboard.welcome')}</h1>
<p className="mt-1 text-muted-foreground">{t('dashboard.subtitle')}</p>
</div>
{/* Quick stats */}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Module active</p>
<p className="text-2xl font-bold">{modules.length}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Companii</p>
<p className="text-2xl font-bold">3</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Instrumente externe</p>
<p className="text-2xl font-bold">{EXTERNAL_TOOLS.length}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Stocare</p>
<p className="text-2xl font-bold">localStorage</p>
</CardContent>
</Card>
</div>
{/* Modules grid */}
<div>
<h2 className="mb-4 text-lg font-semibold">{t('dashboard.modules')}</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{modules.map((m) => (
<ModuleCard key={m.id} module={m} />
))}
</div>
</div>
{/* External tools */}
<div>
<h2 className="mb-4 text-lg font-semibold">Instrumente externe</h2>
<div className="space-y-4">
{toolCategories.map((cat) => (
<div key={cat}>
<Badge variant="outline" className="mb-2">{CATEGORY_LABELS[cat]}</Badge>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{EXTERNAL_TOOLS.filter((tool) => tool.category === cat).map((tool) => {
const cardContent = (
<Card key={tool.id} className="transition-colors hover:bg-accent/30">
<CardHeader className="flex flex-row items-center gap-3 space-y-0 p-4">
<DynamicIcon name={tool.icon} className="h-4 w-4 text-muted-foreground" />
<div>
<p className="text-sm font-medium">{tool.name}</p>
<p className="text-xs text-muted-foreground">{tool.description}</p>
</div>
</CardHeader>
</Card>
);
if (!tool.url) return cardContent;
return (
<a key={tool.id} href={tool.url} target="_blank" rel="noopener noreferrer">
{cardContent}
</a>
);
})}
</div>
</div>
))}
</div>
</div>
</div>
);
}

31
src/app/providers.tsx Normal file
View File

@@ -0,0 +1,31 @@
'use client';
import { ThemeProvider } from '@/core/theme';
import { I18nProvider } from '@/core/i18n';
import { StorageProvider } from '@/core/storage';
import { FeatureFlagProvider } from '@/core/feature-flags';
import { AuthProvider } from '@/core/auth';
import { DEFAULT_FLAGS } from '@/config/flags';
// Ensure module registry is populated
import '@/config/modules';
interface ProvidersProps {
children: React.ReactNode;
}
export function Providers({ children }: ProvidersProps) {
return (
<ThemeProvider>
<I18nProvider>
<StorageProvider>
<FeatureFlagProvider flagDefinitions={DEFAULT_FLAGS}>
<AuthProvider>
{children}
</AuthProvider>
</FeatureFlagProvider>
</StorageProvider>
</I18nProvider>
</ThemeProvider>
);
}

52
src/config/companies.ts Normal file
View File

@@ -0,0 +1,52 @@
import type { CompanyId } from '@/core/auth/types';
export interface Company {
id: CompanyId;
name: string;
shortName: string;
cui: string;
color: string;
address: string;
city: string;
}
export const COMPANIES: Record<CompanyId, Company> = {
beletage: {
id: 'beletage',
name: 'Beletage SRL',
shortName: 'Beletage',
cui: '',
color: '#22B5AB',
address: 'str. Unirii, nr. 3, ap. 26',
city: 'Cluj-Napoca',
},
'urban-switch': {
id: 'urban-switch',
name: 'Urban Switch SRL',
shortName: 'Urban Switch',
cui: '',
color: '#6366f1',
address: '',
city: 'Cluj-Napoca',
},
'studii-de-teren': {
id: 'studii-de-teren',
name: 'Studii de Teren SRL',
shortName: 'Studii de Teren',
cui: '',
color: '#f59e0b',
address: '',
city: 'Cluj-Napoca',
},
group: {
id: 'group',
name: 'Grup Companii',
shortName: 'Grup',
cui: '',
color: '#64748b',
address: '',
city: 'Cluj-Napoca',
},
};
export const COMPANY_LIST = Object.values(COMPANIES);

View File

@@ -0,0 +1,107 @@
export interface ExternalTool {
id: string;
name: string;
description: string;
url: string;
icon: string;
category: 'dev' | 'tools' | 'monitoring' | 'security';
}
export const EXTERNAL_TOOLS: ExternalTool[] = [
{
id: 'gitea',
name: 'Gitea',
description: 'Depozit cod sursă',
url: 'http://10.10.10.166:3002',
icon: 'git-branch',
category: 'dev',
},
{
id: 'portainer',
name: 'Portainer',
description: 'Management containere Docker',
url: '',
icon: 'container',
category: 'dev',
},
{
id: 'minio',
name: 'MinIO',
description: 'Object storage',
url: 'http://10.10.10.166:9003',
icon: 'database',
category: 'dev',
},
{
id: 'n8n',
name: 'N8N',
description: 'Automatizări workflow',
url: 'http://10.10.10.166:5678',
icon: 'workflow',
category: 'tools',
},
{
id: 'stirling-pdf',
name: 'Stirling PDF',
description: 'Manipulare PDF',
url: 'http://10.10.10.166:8087',
icon: 'file-text',
category: 'tools',
},
{
id: 'it-tools',
name: 'IT-Tools',
description: 'Utilitare tehnice',
url: 'http://10.10.10.166:8085',
icon: 'wrench',
category: 'tools',
},
{
id: 'filebrowser',
name: 'Filebrowser',
description: 'Manager fișiere',
url: 'http://10.10.10.166:8086',
icon: 'folder',
category: 'tools',
},
{
id: 'uptime-kuma',
name: 'Uptime Kuma',
description: 'Monitorizare disponibilitate',
url: 'http://10.10.10.166:3001',
icon: 'activity',
category: 'monitoring',
},
{
id: 'netdata',
name: 'Netdata',
description: 'Metrici performanță server',
url: 'http://10.10.10.166:19999',
icon: 'bar-chart-3',
category: 'monitoring',
},
{
id: 'dozzle',
name: 'Dozzle',
description: 'Loguri containere',
url: 'http://10.10.10.166:9999',
icon: 'scroll-text',
category: 'monitoring',
},
{
id: 'crowdsec',
name: 'CrowdSec',
description: 'Protecție și securitate rețea',
url: 'http://10.10.10.166:8088',
icon: 'shield',
category: 'security',
},
{
id: 'authentik',
name: 'Authentik',
description: 'Autentificare SSO',
url: 'http://10.10.10.166:9100',
icon: 'key-round',
category: 'security',
},
];

119
src/config/flags.ts Normal file
View File

@@ -0,0 +1,119 @@
import type { FeatureFlag } from '@/core/feature-flags/types';
export const DEFAULT_FLAGS: FeatureFlag[] = [
// Module flags
{
key: 'module.registratura',
enabled: true,
label: 'Registratură',
description: 'Registru de corespondență multi-firmă',
category: 'module',
overridable: true,
},
{
key: 'module.email-signature',
enabled: true,
label: 'Generator Semnătură Email',
description: 'Configurator semnătură email',
category: 'module',
overridable: true,
},
{
key: 'module.word-xml',
enabled: true,
label: 'Generator XML Word',
description: 'Generator Custom XML Parts pentru Word',
category: 'module',
overridable: true,
},
{
key: 'module.prompt-generator',
enabled: true,
label: 'Generator Prompturi',
description: 'Constructor de prompturi structurate',
category: 'module',
overridable: true,
},
{
key: 'module.digital-signatures',
enabled: false,
label: 'Semnături și Ștampile',
description: 'Bibliotecă semnături digitale',
category: 'module',
overridable: true,
},
{
key: 'module.password-vault',
enabled: false,
label: 'Seif Parole',
description: 'Depozit intern de credențiale',
category: 'module',
overridable: true,
},
{
key: 'module.it-inventory',
enabled: false,
label: 'Inventar IT',
description: 'Evidența echipamentelor',
category: 'module',
overridable: true,
},
{
key: 'module.address-book',
enabled: false,
label: 'Contacte',
description: 'Clienți, furnizori, instituții',
category: 'module',
overridable: true,
},
{
key: 'module.word-templates',
enabled: false,
label: 'Șabloane Word',
description: 'Bibliotecă contracte și rapoarte',
category: 'module',
overridable: true,
},
{
key: 'module.tag-manager',
enabled: true,
label: 'Manager Etichete',
description: 'Administrare etichete',
category: 'module',
overridable: true,
},
{
key: 'module.mini-utilities',
enabled: false,
label: 'Utilitare',
description: 'Calculatoare și instrumente text',
category: 'module',
overridable: true,
},
{
key: 'module.ai-chat',
enabled: false,
label: 'Chat AI',
description: 'Interfață asistent AI',
category: 'module',
overridable: true,
},
// System flags
{
key: 'system.dark-mode',
enabled: true,
label: 'Mod întunecat',
description: 'Activează tema întunecată',
category: 'system',
overridable: true,
},
{
key: 'system.external-links',
enabled: true,
label: 'Linkuri externe',
description: 'Afișează linkuri instrumente externe în navigare',
category: 'system',
overridable: true,
},
];

37
src/config/modules.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { ModuleConfig } from '@/core/module-registry/types';
import { registerModules } from '@/core/module-registry';
import { registraturaConfig } from '@/modules/registratura/config';
import { emailSignatureConfig } from '@/modules/email-signature/config';
import { wordXmlConfig } from '@/modules/word-xml/config';
import { promptGeneratorConfig } from '@/modules/prompt-generator/config';
import { digitalSignaturesConfig } from '@/modules/digital-signatures/config';
import { passwordVaultConfig } from '@/modules/password-vault/config';
import { itInventoryConfig } from '@/modules/it-inventory/config';
import { addressBookConfig } from '@/modules/address-book/config';
import { wordTemplatesConfig } from '@/modules/word-templates/config';
import { tagManagerConfig } from '@/modules/tag-manager/config';
import { miniUtilitiesConfig } from '@/modules/mini-utilities/config';
import { aiChatConfig } from '@/modules/ai-chat/config';
/**
* Toate configurările modulelor ArchiTools, ordonate după navOrder.
* Dashboard-ul nu este inclus deoarece este pagina principală, nu un modul standard.
*/
export const MODULE_CONFIGS: ModuleConfig[] = [
registraturaConfig, // navOrder: 10 | operations
passwordVaultConfig, // navOrder: 11 | operations
emailSignatureConfig, // navOrder: 20 | generators
wordXmlConfig, // navOrder: 21 | generators
wordTemplatesConfig, // navOrder: 22 | generators
digitalSignaturesConfig, // navOrder: 30 | management
itInventoryConfig, // navOrder: 31 | management
addressBookConfig, // navOrder: 32 | management
tagManagerConfig, // navOrder: 40 | tools
miniUtilitiesConfig, // navOrder: 41 | tools
promptGeneratorConfig, // navOrder: 50 | ai
aiChatConfig, // navOrder: 51 | ai
];
// Înregistrare automată a tuturor modulelor în registru
registerModules(MODULE_CONFIGS);

51
src/config/navigation.ts Normal file
View File

@@ -0,0 +1,51 @@
import { getAllModules, MODULE_CATEGORY_LABELS } from '@/core/module-registry';
import type { ModuleCategory } from '@/core/module-registry';
export interface NavGroup {
category: ModuleCategory;
label: string;
items: NavItem[];
}
export interface NavItem {
id: string;
label: string;
icon: string;
href: string;
featureFlag: string;
}
const CATEGORY_ORDER: ModuleCategory[] = [
'operations',
'generators',
'management',
'tools',
'ai',
];
export function buildNavigation(): NavGroup[] {
const modules = getAllModules();
const groups: NavGroup[] = [];
for (const category of CATEGORY_ORDER) {
const items = modules
.filter((m) => m.category === category)
.map((m) => ({
id: m.id,
label: m.name,
icon: m.icon,
href: m.route,
featureFlag: m.featureFlag,
}));
if (items.length > 0) {
groups.push({
category,
label: MODULE_CATEGORY_LABELS[category],
items,
});
}
}
return groups;
}

View File

@@ -0,0 +1,67 @@
'use client';
import { createContext, useContext, useMemo, useCallback } from 'react';
import type { AuthContextValue, User, Role } from './types';
const ROLE_HIERARCHY: Record<Role, number> = {
admin: 4,
manager: 3,
user: 2,
viewer: 1,
guest: 0,
};
const AuthContext = createContext<AuthContextValue | null>(null);
// Stub user for development (no auth required)
const STUB_USER: User = {
id: 'dev-user',
name: 'Utilizator Intern',
email: 'dev@architools.local',
role: 'admin',
company: 'beletage',
};
interface AuthProviderProps {
children: React.ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps) {
// In the current phase, always return the stub user
// Future: replace with Authentik OIDC token resolution
const user = STUB_USER;
const hasRole = useCallback(
(requiredRole: Role) => {
return ROLE_HIERARCHY[user.role] >= ROLE_HIERARCHY[requiredRole];
},
[user.role]
);
const canAccessModule = useCallback(
(_moduleId: string) => {
// Future: check module-level permissions
return true;
},
[]
);
const value: AuthContextValue = useMemo(
() => ({
user,
role: user.role,
isAuthenticated: true,
hasRole,
canAccessModule,
}),
[user, hasRole, canAccessModule]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}

2
src/core/auth/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export type { User, Role, CompanyId, AuthContextValue } from './types';
export { AuthProvider, useAuth } from './auth-provider';

19
src/core/auth/types.ts Normal file
View File

@@ -0,0 +1,19 @@
export type Role = 'admin' | 'manager' | 'user' | 'viewer' | 'guest';
export type CompanyId = 'beletage' | 'urban-switch' | 'studii-de-teren' | 'group';
export interface User {
id: string;
name: string;
email: string;
role: Role;
company: CompanyId;
}
export interface AuthContextValue {
user: User | null;
role: Role;
isAuthenticated: boolean;
hasRole: (role: Role) => boolean;
canAccessModule: (moduleId: string) => boolean;
}

View File

@@ -0,0 +1,14 @@
'use client';
import { useFeatureFlag } from './use-feature-flag';
interface FeatureGateProps {
flag: string;
children: React.ReactNode;
fallback?: React.ReactNode;
}
export function FeatureGate({ flag, children, fallback = null }: FeatureGateProps) {
const enabled = useFeatureFlag(flag);
return enabled ? <>{children}</> : <>{fallback}</>;
}

View File

@@ -0,0 +1,76 @@
'use client';
import { createContext, useContext, useMemo, useState, useCallback } from 'react';
import type { FeatureFlag } from './types';
import { resolveAllFlags, loadRuntimeOverrides, saveRuntimeOverride, clearRuntimeOverrides } from './flag-service';
interface FeatureFlagContextValue {
flags: Record<string, boolean>;
isEnabled: (key: string) => boolean;
setOverride: (key: string, value: boolean) => void;
clearOverrides: () => void;
}
const FeatureFlagContext = createContext<FeatureFlagContextValue | null>(null);
interface FeatureFlagProviderProps {
flagDefinitions: FeatureFlag[];
children: React.ReactNode;
}
export function FeatureFlagProvider({ flagDefinitions, children }: FeatureFlagProviderProps) {
const [runtimeOverrides, setRuntimeOverrides] = useState<Record<string, boolean>>(() => {
if (typeof window === 'undefined') return {};
return loadRuntimeOverrides();
});
const envOverrides = useMemo(() => {
const env: Record<string, string> = {};
if (typeof window !== 'undefined') {
// Collect NEXT_PUBLIC_FLAG_* env vars injected at build time
for (const [key, value] of Object.entries(process.env)) {
if (key.startsWith('NEXT_PUBLIC_FLAG_') && value) {
env[key] = value;
}
}
}
return env;
}, []);
const flags = useMemo(
() => resolveAllFlags(flagDefinitions, envOverrides, runtimeOverrides),
[flagDefinitions, envOverrides, runtimeOverrides]
);
const isEnabled = useCallback((key: string) => flags[key] ?? false, [flags]);
const setOverride = useCallback(
(key: string, value: boolean) => {
saveRuntimeOverride(key, value);
setRuntimeOverrides((prev) => ({ ...prev, [key]: value }));
},
[]
);
const handleClearOverrides = useCallback(() => {
clearRuntimeOverrides();
setRuntimeOverrides({});
}, []);
const value = useMemo(
() => ({ flags, isEnabled, setOverride, clearOverrides: handleClearOverrides }),
[flags, isEnabled, setOverride, handleClearOverrides]
);
return (
<FeatureFlagContext.Provider value={value}>
{children}
</FeatureFlagContext.Provider>
);
}
export function useFeatureFlags(): FeatureFlagContextValue {
const ctx = useContext(FeatureFlagContext);
if (!ctx) throw new Error('useFeatureFlags must be used within FeatureFlagProvider');
return ctx;
}

View File

@@ -0,0 +1,74 @@
import type { FeatureFlag } from './types';
const RUNTIME_OVERRIDE_KEY = 'architools:flag-overrides';
export function resolveFlag(
flag: FeatureFlag,
envOverrides: Record<string, string>,
runtimeOverrides: Record<string, boolean>
): boolean {
// 1. Check environment variable override
const envKey = `NEXT_PUBLIC_FLAG_${flag.key.toUpperCase().replace(/\./g, '_')}`;
const envVal = envOverrides[envKey];
if (envVal !== undefined) {
return envVal === 'true';
}
// 2. Check runtime override
if (flag.overridable && runtimeOverrides[flag.key] !== undefined) {
return runtimeOverrides[flag.key]!;
}
// 3. Fall back to default
return flag.enabled;
}
export function resolveAllFlags(
flags: FeatureFlag[],
envOverrides: Record<string, string>,
runtimeOverrides: Record<string, boolean>
): Record<string, boolean> {
const resolved: Record<string, boolean> = {};
// First pass: resolve without dependency checks
for (const flag of flags) {
resolved[flag.key] = resolveFlag(flag, envOverrides, runtimeOverrides);
}
// Second pass: enforce dependencies
for (const flag of flags) {
if (resolved[flag.key] && flag.dependencies) {
for (const dep of flag.dependencies) {
if (!resolved[dep]) {
resolved[flag.key] = false;
break;
}
}
}
}
return resolved;
}
export function loadRuntimeOverrides(): Record<string, boolean> {
if (typeof window === 'undefined') return {};
try {
const stored = window.localStorage.getItem(RUNTIME_OVERRIDE_KEY);
if (stored) return JSON.parse(stored);
} catch {
// Ignore parse errors
}
return {};
}
export function saveRuntimeOverride(key: string, value: boolean): void {
if (typeof window === 'undefined') return;
const overrides = loadRuntimeOverrides();
overrides[key] = value;
window.localStorage.setItem(RUNTIME_OVERRIDE_KEY, JSON.stringify(overrides));
}
export function clearRuntimeOverrides(): void {
if (typeof window === 'undefined') return;
window.localStorage.removeItem(RUNTIME_OVERRIDE_KEY);
}

View File

@@ -0,0 +1,4 @@
export type { FeatureFlag, FlagCategory } from './types';
export { FeatureFlagProvider, useFeatureFlags } from './flag-provider';
export { useFeatureFlag } from './use-feature-flag';
export { FeatureGate } from './feature-gate';

View File

@@ -0,0 +1,11 @@
export type FlagCategory = 'module' | 'experimental' | 'system';
export interface FeatureFlag {
key: string;
enabled: boolean;
label: string;
description: string;
category: FlagCategory;
dependencies?: string[];
overridable: boolean;
}

View File

@@ -0,0 +1,8 @@
'use client';
import { useFeatureFlags } from './flag-provider';
export function useFeatureFlag(key: string): boolean {
const { isEnabled } = useFeatureFlags();
return isEnabled(key);
}

View File

@@ -0,0 +1,47 @@
'use client';
import { createContext, useContext, useCallback } from 'react';
import { ro } from './locales/ro';
import type { Labels } from './types';
interface I18nContextValue {
labels: Labels;
t: (key: string) => string;
}
const I18nContext = createContext<I18nContextValue | null>(null);
interface I18nProviderProps {
children: React.ReactNode;
}
export function I18nProvider({ children }: I18nProviderProps) {
const labels = ro;
const t = useCallback(
(key: string): string => {
const [namespace, ...rest] = key.split('.');
const labelKey = rest.join('.');
if (!namespace || !labelKey) return key;
return labels[namespace]?.[labelKey] ?? key;
},
[labels]
);
return (
<I18nContext.Provider value={{ labels, t }}>
{children}
</I18nContext.Provider>
);
}
export function useI18n(): I18nContextValue {
const ctx = useContext(I18nContext);
if (!ctx) throw new Error('useI18n must be used within I18nProvider');
return ctx;
}
export function useLabel(key: string): string {
const { t } = useI18n();
return t(key);
}

2
src/core/i18n/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export type { Labels, LabelNamespace } from './types';
export { I18nProvider, useI18n, useLabel } from './i18n-provider';

109
src/core/i18n/locales/ro.ts Normal file
View File

@@ -0,0 +1,109 @@
import type { Labels } from '../types';
export const ro: Labels = {
common: {
save: 'Salvează',
cancel: 'Anulează',
delete: 'Șterge',
edit: 'Editează',
create: 'Creează',
search: 'Caută',
filter: 'Filtrează',
export: 'Exportă',
import: 'Importă',
copy: 'Copiază',
close: 'Închide',
confirm: 'Confirmă',
back: 'Înapoi',
next: 'Următorul',
loading: 'Se încarcă...',
noResults: 'Niciun rezultat',
error: 'Eroare',
success: 'Succes',
actions: 'Acțiuni',
settings: 'Setări',
all: 'Toate',
yes: 'Da',
no: 'Nu',
},
nav: {
dashboard: 'Panou principal',
operations: 'Operațiuni',
generators: 'Generatoare',
management: 'Management',
tools: 'Instrumente',
ai: 'AI & Automatizări',
externalTools: 'Instrumente externe',
},
dashboard: {
title: 'Panou principal',
welcome: 'Bine ai venit în ArchiTools',
subtitle: 'Platforma internă de instrumente pentru birou',
quickActions: 'Acțiuni rapide',
recentActivity: 'Activitate recentă',
modules: 'Module',
infrastructure: 'Infrastructură',
},
registratura: {
title: 'Registratură',
description: 'Registru de corespondență multi-firmă',
newEntry: 'Înregistrare nouă',
entries: 'Înregistrări',
incoming: 'Intrare',
outgoing: 'Ieșire',
internal: 'Intern',
},
'email-signature': {
title: 'Generator Semnătură Email',
description: 'Configurator semnătură email pentru companii',
preview: 'Previzualizare',
downloadHtml: 'Descarcă HTML',
},
'word-xml': {
title: 'Generator XML Word',
description: 'Generator Custom XML Parts pentru Word',
generate: 'Generează XML',
downloadXml: 'Descarcă XML',
downloadZip: 'Descarcă ZIP',
},
'prompt-generator': {
title: 'Generator Prompturi',
description: 'Constructor de prompturi structurate pentru AI',
templates: 'Șabloane',
compose: 'Compune',
history: 'Istoric',
preview: 'Previzualizare',
},
'digital-signatures': {
title: 'Semnături și Ștampile',
description: 'Bibliotecă semnături digitale și ștampile scanate',
},
'password-vault': {
title: 'Seif Parole',
description: 'Depozit intern de credențiale partajate',
},
'it-inventory': {
title: 'Inventar IT',
description: 'Evidența echipamentelor și dispozitivelor',
},
'address-book': {
title: 'Contacte',
description: 'Clienți, furnizori, instituții',
},
'word-templates': {
title: 'Șabloane Word',
description: 'Bibliotecă contracte, oferte, rapoarte',
},
'tag-manager': {
title: 'Manager Etichete',
description: 'Administrare etichete proiecte și categorii',
},
'mini-utilities': {
title: 'Utilitare',
description: 'Calculatoare tehnice și instrumente text',
},
'ai-chat': {
title: 'Chat AI',
description: 'Interfață asistent AI',
},
};

3
src/core/i18n/types.ts Normal file
View File

@@ -0,0 +1,3 @@
export type LabelNamespace = 'common' | 'nav' | 'dashboard' | string;
export type Labels = Record<string, Record<string, string>>;

View File

@@ -0,0 +1,9 @@
export type { ModuleConfig, ModuleCategory, Visibility } from './types';
export { MODULE_CATEGORY_LABELS } from './types';
export {
registerModules,
getAllModules,
getModuleById,
getModuleByRoute,
getModulesByCategory,
} from './registry';

View File

@@ -0,0 +1,45 @@
import type { ModuleConfig, ModuleCategory } from './types';
let registeredModules: ModuleConfig[] = [];
let moduleMap: Map<string, ModuleConfig> = new Map();
export function registerModules(configs: ModuleConfig[]): void {
if (process.env.NODE_ENV === 'development') {
validateRegistry(configs);
}
registeredModules = [...configs].sort((a, b) => {
if (a.category !== b.category) return a.category.localeCompare(b.category);
return a.navOrder - b.navOrder;
});
moduleMap = new Map(registeredModules.map((c) => [c.id, c]));
}
export function getAllModules(): ModuleConfig[] {
return registeredModules;
}
export function getModuleById(id: string): ModuleConfig | undefined {
return moduleMap.get(id);
}
export function getModuleByRoute(route: string): ModuleConfig | undefined {
return registeredModules.find((m) => m.route === route);
}
export function getModulesByCategory(category: ModuleCategory): ModuleConfig[] {
return registeredModules.filter((m) => m.category === category);
}
function validateRegistry(configs: ModuleConfig[]): void {
const ids = configs.map((m) => m.id);
const duplicateIds = ids.filter((id, i) => ids.indexOf(id) !== i);
if (duplicateIds.length > 0) {
throw new Error(`Duplicate module IDs: ${duplicateIds.join(', ')}`);
}
const routes = configs.map((m) => m.route);
const duplicateRoutes = routes.filter((r, i) => routes.indexOf(r) !== i);
if (duplicateRoutes.length > 0) {
throw new Error(`Duplicate module routes: ${duplicateRoutes.join(', ')}`);
}
}

View File

@@ -0,0 +1,32 @@
export type ModuleCategory =
| 'operations'
| 'generators'
| 'management'
| 'tools'
| 'ai';
export type Visibility = 'all' | 'internal' | 'admin' | 'guest-safe';
export interface ModuleConfig {
id: string;
name: string;
description: string;
icon: string;
route: string;
category: ModuleCategory;
featureFlag: string;
visibility: Visibility;
version: string;
dependencies?: string[];
storageNamespace: string;
navOrder: number;
tags?: string[];
}
export const MODULE_CATEGORY_LABELS: Record<ModuleCategory, string> = {
operations: 'Operațiuni',
generators: 'Generatoare',
management: 'Management',
tools: 'Instrumente',
ai: 'AI & Automatizări',
};

View File

@@ -0,0 +1,80 @@
import type { StorageService } from '../types';
function nsKey(namespace: string, key: string): string {
return `architools:${namespace}:${key}`;
}
function nsPrefix(namespace: string): string {
return `architools:${namespace}:`;
}
export class LocalStorageAdapter implements StorageService {
async get<T>(namespace: string, key: string): Promise<T | null> {
if (typeof window === 'undefined') return null;
try {
const raw = window.localStorage.getItem(nsKey(namespace, key));
if (raw === null) return null;
return JSON.parse(raw) as T;
} catch {
return null;
}
}
async set<T>(namespace: string, key: string, value: T): Promise<void> {
if (typeof window === 'undefined') return;
window.localStorage.setItem(nsKey(namespace, key), JSON.stringify(value));
}
async delete(namespace: string, key: string): Promise<void> {
if (typeof window === 'undefined') return;
window.localStorage.removeItem(nsKey(namespace, key));
}
async list(namespace: string): Promise<string[]> {
if (typeof window === 'undefined') return [];
const prefix = nsPrefix(namespace);
const keys: string[] = [];
for (let i = 0; i < window.localStorage.length; i++) {
const k = window.localStorage.key(i);
if (k && k.startsWith(prefix)) {
keys.push(k.slice(prefix.length));
}
}
return keys;
}
async query<T>(namespace: string, predicate: (item: T) => boolean): Promise<T[]> {
const keys = await this.list(namespace);
const results: T[] = [];
for (const key of keys) {
const item = await this.get<T>(namespace, key);
if (item !== null && predicate(item)) {
results.push(item);
}
}
return results;
}
async clear(namespace: string): Promise<void> {
if (typeof window === 'undefined') return;
const keys = await this.list(namespace);
for (const key of keys) {
window.localStorage.removeItem(nsKey(namespace, key));
}
}
async export(namespace: string): Promise<Record<string, unknown>> {
const keys = await this.list(namespace);
const data: Record<string, unknown> = {};
for (const key of keys) {
data[key] = await this.get(namespace, key);
}
return data;
}
async import(namespace: string, data: Record<string, unknown>): Promise<void> {
for (const [key, value] of Object.entries(data)) {
await this.set(namespace, key, value);
}
}
}

View File

@@ -0,0 +1,4 @@
export type { StorageService } from './types';
export { StorageProvider, useStorageService } from './storage-provider';
export { useStorage } from './use-storage';
export type { NamespacedStorage } from './use-storage';

View File

@@ -0,0 +1,33 @@
'use client';
import { createContext, useContext, useMemo } from 'react';
import type { StorageService } from './types';
import { LocalStorageAdapter } from './adapters/local-storage';
const StorageContext = createContext<StorageService | null>(null);
function createAdapter(): StorageService {
// Future: select adapter based on environment variable
// const adapterType = process.env.NEXT_PUBLIC_STORAGE_ADAPTER;
return new LocalStorageAdapter();
}
interface StorageProviderProps {
children: React.ReactNode;
}
export function StorageProvider({ children }: StorageProviderProps) {
const service = useMemo(() => createAdapter(), []);
return (
<StorageContext.Provider value={service}>
{children}
</StorageContext.Provider>
);
}
export function useStorageService(): StorageService {
const ctx = useContext(StorageContext);
if (!ctx) throw new Error('useStorageService must be used within StorageProvider');
return ctx;
}

10
src/core/storage/types.ts Normal file
View File

@@ -0,0 +1,10 @@
export interface StorageService {
get<T>(namespace: string, key: string): Promise<T | null>;
set<T>(namespace: string, key: string, value: T): Promise<void>;
delete(namespace: string, key: string): Promise<void>;
list(namespace: string): Promise<string[]>;
query<T>(namespace: string, predicate: (item: T) => boolean): Promise<T[]>;
clear(namespace: string): Promise<void>;
export(namespace: string): Promise<Record<string, unknown>>;
import(namespace: string, data: Record<string, unknown>): Promise<void>;
}

View File

@@ -0,0 +1,55 @@
'use client';
import { useCallback, useMemo } from 'react';
import { useStorageService } from './storage-provider';
export interface NamespacedStorage {
get: <T>(key: string) => Promise<T | null>;
set: <T>(key: string, value: T) => Promise<void>;
delete: (key: string) => Promise<void>;
list: () => Promise<string[]>;
query: <T>(predicate: (item: T) => boolean) => Promise<T[]>;
clear: () => Promise<void>;
exportAll: () => Promise<Record<string, unknown>>;
importAll: (data: Record<string, unknown>) => Promise<void>;
}
export function useStorage(namespace: string): NamespacedStorage {
const service = useStorageService();
const get = useCallback(
<T,>(key: string) => service.get<T>(namespace, key),
[service, namespace]
);
const set = useCallback(
<T,>(key: string, value: T) => service.set<T>(namespace, key, value),
[service, namespace]
);
const del = useCallback(
(key: string) => service.delete(namespace, key),
[service, namespace]
);
const list = useCallback(() => service.list(namespace), [service, namespace]);
const query = useCallback(
<T,>(predicate: (item: T) => boolean) => service.query<T>(namespace, predicate),
[service, namespace]
);
const clear = useCallback(() => service.clear(namespace), [service, namespace]);
const exportAll = useCallback(() => service.export(namespace), [service, namespace]);
const importAll = useCallback(
(data: Record<string, unknown>) => service.import(namespace, data),
[service, namespace]
);
return useMemo(
() => ({ get, set, delete: del, list, query, clear, exportAll, importAll }),
[get, set, del, list, query, clear, exportAll, importAll]
);
}

View File

@@ -0,0 +1,3 @@
export type { Tag, TagCategory, TagScope } from './types';
export { TagService } from './tag-service';
export { useTags } from './use-tags';

View File

@@ -0,0 +1,61 @@
import type { StorageService } from '@/core/storage/types';
import type { Tag, TagCategory, TagScope } from './types';
import { v4 as uuid } from 'uuid';
const NAMESPACE = 'tags';
export class TagService {
constructor(private storage: StorageService) {}
async getAllTags(): Promise<Tag[]> {
const keys = await this.storage.list(NAMESPACE);
const tags: Tag[] = [];
for (const key of keys) {
const tag = await this.storage.get<Tag>(NAMESPACE, key);
if (tag) tags.push(tag);
}
return tags;
}
async getTagsByCategory(category: TagCategory): Promise<Tag[]> {
return this.storage.query<Tag>(NAMESPACE, (tag) => tag.category === category);
}
async getTagsByScope(scope: TagScope, scopeId?: string): Promise<Tag[]> {
return this.storage.query<Tag>(NAMESPACE, (tag) => {
if (tag.scope !== scope) return false;
if (scope === 'module' && scopeId) return tag.moduleId === scopeId;
if (scope === 'company' && scopeId) return tag.companyId === scopeId;
return true;
});
}
async createTag(data: Omit<Tag, 'id' | 'createdAt'>): Promise<Tag> {
const tag: Tag = {
...data,
id: uuid(),
createdAt: new Date().toISOString(),
};
await this.storage.set(NAMESPACE, tag.id, tag);
return tag;
}
async updateTag(id: string, updates: Partial<Omit<Tag, 'id' | 'createdAt'>>): Promise<Tag | null> {
const existing = await this.storage.get<Tag>(NAMESPACE, id);
if (!existing) return null;
const updated = { ...existing, ...updates };
await this.storage.set(NAMESPACE, id, updated);
return updated;
}
async deleteTag(id: string): Promise<void> {
await this.storage.delete(NAMESPACE, id);
}
async searchTags(query: string): Promise<Tag[]> {
const lower = query.toLowerCase();
return this.storage.query<Tag>(NAMESPACE, (tag) =>
tag.label.toLowerCase().includes(lower)
);
}
}

27
src/core/tagging/types.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { CompanyId } from '@/core/auth/types';
export type TagCategory =
| 'project'
| 'phase'
| 'activity'
| 'document-type'
| 'company'
| 'priority'
| 'status'
| 'custom';
export type TagScope = 'global' | 'module' | 'company';
export interface Tag {
id: string;
label: string;
category: TagCategory;
color?: string;
icon?: string;
scope: TagScope;
moduleId?: string;
companyId?: CompanyId;
parentId?: string;
metadata?: Record<string, string>;
createdAt: string;
}

View File

@@ -0,0 +1,45 @@
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useStorageService } from '@/core/storage';
import { TagService } from './tag-service';
import type { Tag, TagCategory } from './types';
export function useTags(category?: TagCategory) {
const storage = useStorageService();
const service = useMemo(() => new TagService(storage), [storage]);
const [tags, setTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
setLoading(true);
const result = category
? await service.getTagsByCategory(category)
: await service.getAllTags();
setTags(result);
setLoading(false);
}, [service, category]);
// Load initial data from external storage
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refresh(); }, [refresh]);
const createTag = useCallback(
async (data: Omit<Tag, 'id' | 'createdAt'>) => {
const tag = await service.createTag(data);
await refresh();
return tag;
},
[service, refresh]
);
const deleteTag = useCallback(
async (id: string) => {
await service.deleteTag(id);
await refresh();
},
[service, refresh]
);
return { tags, loading, createTag, deleteTag, refresh };
}

1
src/core/theme/index.ts Normal file
View File

@@ -0,0 +1 @@
export { ThemeProvider } from './theme-provider';

View File

@@ -0,0 +1,20 @@
'use client';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
interface ThemeProviderProps {
children: React.ReactNode;
}
export function ThemeProvider({ children }: ThemeProviderProps) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
);
}

View File

@@ -0,0 +1,168 @@
'use client';
import { useState } from 'react';
import { Plus, Pencil, Trash2, Search, Mail, Phone, MapPin } from 'lucide-react';
import { Button } from '@/shared/components/ui/button';
import { Input } from '@/shared/components/ui/input';
import { Label } from '@/shared/components/ui/label';
import { Textarea } from '@/shared/components/ui/textarea';
import { Badge } from '@/shared/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
import type { AddressContact, ContactType } from '../types';
import { useContacts } from '../hooks/use-contacts';
const TYPE_LABELS: Record<ContactType, string> = {
client: 'Client', supplier: 'Furnizor', institution: 'Instituție', collaborator: 'Colaborator',
};
type ViewMode = 'list' | 'add' | 'edit';
export function AddressBookModule() {
const { contacts, allContacts, loading, filters, updateFilter, addContact, updateContact, removeContact } = useContacts();
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [editingContact, setEditingContact] = useState<AddressContact | null>(null);
const handleSubmit = async (data: Omit<AddressContact, 'id' | 'createdAt'>) => {
if (viewMode === 'edit' && editingContact) {
await updateContact(editingContact.id, data);
} else {
await addContact(data);
}
setViewMode('list');
setEditingContact(null);
};
return (
<div className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total</p><p className="text-2xl font-bold">{allContacts.length}</p></CardContent></Card>
{(Object.keys(TYPE_LABELS) as ContactType[]).slice(0, 3).map((type) => (
<Card key={type}><CardContent className="p-4">
<p className="text-xs text-muted-foreground">{TYPE_LABELS[type]}</p>
<p className="text-2xl font-bold">{allContacts.filter((c) => c.type === type).length}</p>
</CardContent></Card>
))}
</div>
{viewMode === 'list' && (
<>
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Caută contact..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
</div>
<Select value={filters.type} onValueChange={(v) => updateFilter('type', v as ContactType | 'all')}>
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate tipurile</SelectItem>
{(Object.keys(TYPE_LABELS) as ContactType[]).map((t) => (
<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={() => setViewMode('add')} className="shrink-0">
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button>
</div>
{loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
) : contacts.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Niciun contact găsit.</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{contacts.map((contact) => (
<Card key={contact.id} className="group relative">
<CardContent className="p-4">
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingContact(contact); setViewMode('edit'); }}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeContact(contact.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-2">
<div>
<p className="font-medium">{contact.name}</p>
<div className="flex items-center gap-2">
{contact.company && <p className="text-xs text-muted-foreground">{contact.company}</p>}
<Badge variant="outline" className="text-[10px]">{TYPE_LABELS[contact.type]}</Badge>
</div>
</div>
{contact.email && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Mail className="h-3 w-3" /><span>{contact.email}</span>
</div>
)}
{contact.phone && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Phone className="h-3 w-3" /><span>{contact.phone}</span>
</div>
)}
{contact.address && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPin className="h-3 w-3" /><span className="truncate">{contact.address}</span>
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
</>
)}
{(viewMode === 'add' || viewMode === 'edit') && (
<Card>
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare contact' : 'Contact nou'}</CardTitle></CardHeader>
<CardContent>
<ContactForm initial={editingContact ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingContact(null); }} />
</CardContent>
</Card>
)}
</div>
);
}
function ContactForm({ initial, onSubmit, onCancel }: {
initial?: AddressContact;
onSubmit: (data: Omit<AddressContact, 'id' | 'createdAt'>) => void;
onCancel: () => void;
}) {
const [name, setName] = useState(initial?.name ?? '');
const [company, setCompany] = useState(initial?.company ?? '');
const [type, setType] = useState<ContactType>(initial?.type ?? 'client');
const [email, setEmail] = useState(initial?.email ?? '');
const [phone, setPhone] = useState(initial?.phone ?? '');
const [address, setAddress] = useState(initial?.address ?? '');
const [notes, setNotes] = useState(initial?.notes ?? '');
return (
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ name, company, type, email, phone, address, notes, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all' }); }} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div><Label>Nume</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div>
<div><Label>Companie/Organizație</Label><Input value={company} onChange={(e) => setCompany(e.target.value)} className="mt-1" /></div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div><Label>Tip</Label>
<Select value={type} onValueChange={(v) => setType(v as ContactType)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>{(Object.keys(TYPE_LABELS) as ContactType[]).map((t) => (<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem>))}</SelectContent>
</Select>
</div>
<div><Label>Email</Label><Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="mt-1" /></div>
<div><Label>Telefon</Label><Input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} className="mt-1" /></div>
</div>
<div><Label>Adresă</Label><Input value={address} onChange={(e) => setAddress(e.target.value)} className="mt-1" /></div>
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,17 @@
import type { ModuleConfig } from '@/core/module-registry/types';
export const addressBookConfig: ModuleConfig = {
id: 'address-book',
name: 'Contacte',
description: 'Agendă de contacte organizată pe tipuri: clienți, furnizori, instituții, colaboratori',
icon: 'users',
route: '/address-book',
category: 'management',
featureFlag: 'module.address-book',
visibility: 'all',
version: '0.1.0',
dependencies: [],
storageNamespace: 'address-book',
navOrder: 32,
tags: ['contacte', 'agendă', 'clienți'],
};

View File

@@ -0,0 +1,73 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useStorage } from '@/core/storage';
import { v4 as uuid } from 'uuid';
import type { AddressContact, ContactType } from '../types';
const PREFIX = 'contact:';
export interface ContactFilters {
search: string;
type: ContactType | 'all';
}
export function useContacts() {
const storage = useStorage('address-book');
const [contacts, setContacts] = useState<AddressContact[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<ContactFilters>({ search: '', type: 'all' });
const refresh = useCallback(async () => {
setLoading(true);
const keys = await storage.list();
const results: AddressContact[] = [];
for (const key of keys) {
if (key.startsWith(PREFIX)) {
const item = await storage.get<AddressContact>(key);
if (item) results.push(item);
}
}
results.sort((a, b) => a.name.localeCompare(b.name));
setContacts(results);
setLoading(false);
}, [storage]);
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refresh(); }, [refresh]);
const addContact = useCallback(async (data: Omit<AddressContact, 'id' | 'createdAt'>) => {
const contact: AddressContact = { ...data, id: uuid(), createdAt: new Date().toISOString() };
await storage.set(`${PREFIX}${contact.id}`, contact);
await refresh();
return contact;
}, [storage, refresh]);
const updateContact = useCallback(async (id: string, updates: Partial<AddressContact>) => {
const existing = contacts.find((c) => c.id === id);
if (!existing) return;
const updated = { ...existing, ...updates, id: existing.id, createdAt: existing.createdAt };
await storage.set(`${PREFIX}${id}`, updated);
await refresh();
}, [storage, refresh, contacts]);
const removeContact = useCallback(async (id: string) => {
await storage.delete(`${PREFIX}${id}`);
await refresh();
}, [storage, refresh]);
const updateFilter = useCallback(<K extends keyof ContactFilters>(key: K, value: ContactFilters[K]) => {
setFilters((prev) => ({ ...prev, [key]: value }));
}, []);
const filteredContacts = contacts.filter((c) => {
if (filters.type !== 'all' && c.type !== filters.type) return false;
if (filters.search) {
const q = filters.search.toLowerCase();
return c.name.toLowerCase().includes(q) || c.company.toLowerCase().includes(q) || c.email.toLowerCase().includes(q) || c.phone.includes(q);
}
return true;
});
return { contacts: filteredContacts, allContacts: contacts, loading, filters, updateFilter, addContact, updateContact, removeContact, refresh };
}

View File

@@ -0,0 +1,3 @@
export { addressBookConfig } from './config';
export { AddressBookModule } from './components/address-book-module';
export type { AddressContact, ContactType } from './types';

View File

@@ -0,0 +1,17 @@
import type { Visibility } from '@/core/module-registry/types';
export type ContactType = 'client' | 'supplier' | 'institution' | 'collaborator';
export interface AddressContact {
id: string;
name: string;
company: string;
type: ContactType;
email: string;
phone: string;
address: string;
tags: string[];
notes: string;
visibility: Visibility;
createdAt: string;
}

View File

@@ -0,0 +1,164 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { Plus, Send, Trash2, MessageSquare, Settings } from 'lucide-react';
import { Button } from '@/shared/components/ui/button';
import { Input } from '@/shared/components/ui/input';
import { Card, CardContent } from '@/shared/components/ui/card';
import { Badge } from '@/shared/components/ui/badge';
import { cn } from '@/shared/lib/utils';
import { useChat } from '../hooks/use-chat';
export function AiChatModule() {
const {
sessions, activeSession, activeSessionId,
createSession, addMessage, deleteSession, selectSession,
} = useChat();
const [input, setInput] = useState('');
const [showConfig, setShowConfig] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [activeSession?.messages.length]);
const handleSend = async () => {
if (!input.trim()) return;
const text = input.trim();
setInput('');
if (!activeSessionId) {
await createSession();
}
await addMessage(text, 'user');
// Simulate AI response (no real API connected)
setTimeout(async () => {
await addMessage(
'Acest modul necesită configurarea unei conexiuni API către un model AI (ex: Claude, GPT). ' +
'Momentan funcționează în mod demonstrativ — mesajele sunt salvate local, dar răspunsurile AI nu sunt generate.\n\n' +
'Pentru a activa răspunsurile AI, configurați cheia API în setările modulului.',
'assistant'
);
}, 500);
};
return (
<div className="flex h-[calc(100vh-12rem)] gap-4">
{/* Sidebar - sessions */}
<div className="hidden w-64 shrink-0 flex-col gap-2 md:flex">
<Button onClick={() => createSession()} size="sm" className="w-full">
<Plus className="mr-1.5 h-3.5 w-3.5" /> Conversație nouă
</Button>
<div className="flex-1 space-y-1 overflow-y-auto">
{sessions.map((session) => (
<div
key={session.id}
className={cn(
'group flex cursor-pointer items-center justify-between rounded-md px-2 py-1.5 text-sm transition-colors',
session.id === activeSessionId ? 'bg-accent' : 'hover:bg-accent/50'
)}
onClick={() => selectSession(session.id)}
>
<div className="flex min-w-0 items-center gap-1.5">
<MessageSquare className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{session.title}</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100"
onClick={(e) => { e.stopPropagation(); deleteSession(session.id); }}
>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
))}
</div>
</div>
{/* Main chat area */}
<div className="flex flex-1 flex-col rounded-lg border">
{/* Header */}
<div className="flex items-center justify-between border-b px-4 py-2">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium">
{activeSession?.title ?? 'Chat AI'}
</h3>
<Badge variant="outline" className="text-[10px]">Demo</Badge>
</div>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setShowConfig(!showConfig)}>
<Settings className="h-3.5 w-3.5" />
</Button>
</div>
{/* Config banner */}
{showConfig && (
<div className="border-b bg-muted/30 px-4 py-3 text-xs text-muted-foreground">
<p className="font-medium">Configurare API (viitor)</p>
<p className="mt-1">
Modulul va suporta conectarea la API-uri AI (Anthropic Claude, OpenAI, modele locale via Ollama).
Cheia API și endpoint-ul se vor configura din setările aplicației sau variabile de mediu.
</p>
<p className="mt-1">
Momentan, conversațiile sunt salvate local, dar fără generare de răspunsuri AI reale.
</p>
</div>
)}
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4">
{!activeSession || activeSession.messages.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<MessageSquare className="mb-3 h-10 w-10 opacity-30" />
<p className="text-sm">Începe o conversație nouă</p>
<p className="mt-1 text-xs">Scrie un mesaj sau creează o sesiune nouă din bara laterală.</p>
</div>
) : (
<div className="space-y-4">
{activeSession.messages.map((msg) => (
<div
key={msg.id}
className={cn(
'max-w-[80%] rounded-lg px-3 py-2 text-sm',
msg.role === 'user'
? 'ml-auto bg-primary text-primary-foreground'
: 'bg-muted'
)}
>
<p className="whitespace-pre-wrap">{msg.content}</p>
<p className={cn(
'mt-1 text-[10px]',
msg.role === 'user' ? 'text-primary-foreground/60' : 'text-muted-foreground'
)}>
{new Date(msg.timestamp).toLocaleTimeString('ro-RO', { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
))}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Input */}
<div className="border-t p-3">
<div className="flex gap-2">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
placeholder="Scrie un mesaj..."
className="flex-1"
/>
<Button onClick={handleSend} disabled={!input.trim()}>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import type { ModuleConfig } from '@/core/module-registry/types';
export const aiChatConfig: ModuleConfig = {
id: 'ai-chat',
name: 'Chat AI',
description: 'Interfață de conversație cu modele AI pentru asistență profesională',
icon: 'message-square',
route: '/ai-chat',
category: 'ai',
featureFlag: 'module.ai-chat',
visibility: 'all',
version: '0.1.0',
dependencies: [],
storageNamespace: 'ai-chat',
navOrder: 51,
tags: ['chat', 'ai', 'conversație'],
};

View File

@@ -0,0 +1,93 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useStorage } from '@/core/storage';
import { v4 as uuid } from 'uuid';
import type { ChatMessage, ChatSession } from '../types';
const SESSION_PREFIX = 'session:';
export function useChat() {
const storage = useStorage('ai-chat');
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const activeSession = sessions.find((s) => s.id === activeSessionId) ?? null;
const refresh = useCallback(async () => {
setLoading(true);
const keys = await storage.list();
const results: ChatSession[] = [];
for (const key of keys) {
if (key.startsWith(SESSION_PREFIX)) {
const session = await storage.get<ChatSession>(key);
if (session) results.push(session);
}
}
results.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
setSessions(results);
setLoading(false);
}, [storage]);
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refresh(); }, [refresh]);
const createSession = useCallback(async (title?: string) => {
const session: ChatSession = {
id: uuid(),
title: title || `Conversație ${new Date().toLocaleDateString('ro-RO')}`,
messages: [],
createdAt: new Date().toISOString(),
};
await storage.set(`${SESSION_PREFIX}${session.id}`, session);
setSessions((prev) => [session, ...prev]);
setActiveSessionId(session.id);
return session;
}, [storage]);
const addMessage = useCallback(async (content: string, role: ChatMessage['role']) => {
if (!activeSessionId) return;
const message: ChatMessage = {
id: uuid(),
role,
content,
timestamp: new Date().toISOString(),
};
setSessions((prev) => prev.map((s) => {
if (s.id !== activeSessionId) return s;
return { ...s, messages: [...s.messages, message] };
}));
// Persist
const current = sessions.find((s) => s.id === activeSessionId);
if (current) {
const updated = { ...current, messages: [...current.messages, message] };
await storage.set(`${SESSION_PREFIX}${activeSessionId}`, updated);
}
return message;
}, [storage, activeSessionId, sessions]);
const deleteSession = useCallback(async (id: string) => {
await storage.delete(`${SESSION_PREFIX}${id}`);
setSessions((prev) => prev.filter((s) => s.id !== id));
if (activeSessionId === id) {
setActiveSessionId(null);
}
}, [storage, activeSessionId]);
const selectSession = useCallback((id: string) => {
setActiveSessionId(id);
}, []);
return {
sessions,
activeSession,
activeSessionId,
loading,
createSession,
addMessage,
deleteSession,
selectSession,
refresh,
};
}

View File

@@ -0,0 +1,3 @@
export { aiChatConfig } from './config';
export { AiChatModule } from './components/ai-chat-module';
export type { ChatMessage, ChatRole, ChatSession } from './types';

Some files were not shown because too many files have changed in this diff Show More