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:
29
src/app/(modules)/address-book/page.tsx
Normal file
29
src/app/(modules)/address-book/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/app/(modules)/ai-chat/page.tsx
Normal file
29
src/app/(modules)/ai-chat/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/app/(modules)/digital-signatures/page.tsx
Normal file
29
src/app/(modules)/digital-signatures/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/app/(modules)/email-signature/page.tsx
Normal file
29
src/app/(modules)/email-signature/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/app/(modules)/it-inventory/page.tsx
Normal file
29
src/app/(modules)/it-inventory/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/app/(modules)/mini-utilities/page.tsx
Normal file
29
src/app/(modules)/mini-utilities/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/app/(modules)/password-vault/page.tsx
Normal file
29
src/app/(modules)/password-vault/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/app/(modules)/prompt-generator/page.tsx
Normal file
29
src/app/(modules)/prompt-generator/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/app/(modules)/registratura/page.tsx
Normal file
29
src/app/(modules)/registratura/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/app/(modules)/tag-manager/page.tsx
Normal file
29
src/app/(modules)/tag-manager/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/app/(modules)/word-templates/page.tsx
Normal file
29
src/app/(modules)/word-templates/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/app/(modules)/word-xml/page.tsx
Normal file
29
src/app/(modules)/word-xml/page.tsx
Normal 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
126
src/app/globals.css
Normal file
126
src/app/globals.css
Normal 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
29
src/app/layout.tsx
Normal 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
18
src/app/not-found.tsx
Normal 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
134
src/app/page.tsx
Normal 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
31
src/app/providers.tsx
Normal 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
52
src/config/companies.ts
Normal 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);
|
||||
107
src/config/external-tools.ts
Normal file
107
src/config/external-tools.ts
Normal 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
119
src/config/flags.ts
Normal 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
37
src/config/modules.ts
Normal 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
51
src/config/navigation.ts
Normal 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;
|
||||
}
|
||||
67
src/core/auth/auth-provider.tsx
Normal file
67
src/core/auth/auth-provider.tsx
Normal 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
2
src/core/auth/index.ts
Normal 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
19
src/core/auth/types.ts
Normal 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;
|
||||
}
|
||||
14
src/core/feature-flags/feature-gate.tsx
Normal file
14
src/core/feature-flags/feature-gate.tsx
Normal 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}</>;
|
||||
}
|
||||
76
src/core/feature-flags/flag-provider.tsx
Normal file
76
src/core/feature-flags/flag-provider.tsx
Normal 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;
|
||||
}
|
||||
74
src/core/feature-flags/flag-service.ts
Normal file
74
src/core/feature-flags/flag-service.ts
Normal 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);
|
||||
}
|
||||
4
src/core/feature-flags/index.ts
Normal file
4
src/core/feature-flags/index.ts
Normal 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';
|
||||
11
src/core/feature-flags/types.ts
Normal file
11
src/core/feature-flags/types.ts
Normal 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;
|
||||
}
|
||||
8
src/core/feature-flags/use-feature-flag.ts
Normal file
8
src/core/feature-flags/use-feature-flag.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useFeatureFlags } from './flag-provider';
|
||||
|
||||
export function useFeatureFlag(key: string): boolean {
|
||||
const { isEnabled } = useFeatureFlags();
|
||||
return isEnabled(key);
|
||||
}
|
||||
47
src/core/i18n/i18n-provider.tsx
Normal file
47
src/core/i18n/i18n-provider.tsx
Normal 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
2
src/core/i18n/index.ts
Normal 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
109
src/core/i18n/locales/ro.ts
Normal 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
3
src/core/i18n/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type LabelNamespace = 'common' | 'nav' | 'dashboard' | string;
|
||||
|
||||
export type Labels = Record<string, Record<string, string>>;
|
||||
9
src/core/module-registry/index.ts
Normal file
9
src/core/module-registry/index.ts
Normal 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';
|
||||
45
src/core/module-registry/registry.ts
Normal file
45
src/core/module-registry/registry.ts
Normal 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(', ')}`);
|
||||
}
|
||||
}
|
||||
32
src/core/module-registry/types.ts
Normal file
32
src/core/module-registry/types.ts
Normal 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',
|
||||
};
|
||||
80
src/core/storage/adapters/local-storage.ts
Normal file
80
src/core/storage/adapters/local-storage.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
4
src/core/storage/index.ts
Normal file
4
src/core/storage/index.ts
Normal 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';
|
||||
33
src/core/storage/storage-provider.tsx
Normal file
33
src/core/storage/storage-provider.tsx
Normal 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
10
src/core/storage/types.ts
Normal 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>;
|
||||
}
|
||||
55
src/core/storage/use-storage.ts
Normal file
55
src/core/storage/use-storage.ts
Normal 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]
|
||||
);
|
||||
}
|
||||
3
src/core/tagging/index.ts
Normal file
3
src/core/tagging/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type { Tag, TagCategory, TagScope } from './types';
|
||||
export { TagService } from './tag-service';
|
||||
export { useTags } from './use-tags';
|
||||
61
src/core/tagging/tag-service.ts
Normal file
61
src/core/tagging/tag-service.ts
Normal 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
27
src/core/tagging/types.ts
Normal 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;
|
||||
}
|
||||
45
src/core/tagging/use-tags.ts
Normal file
45
src/core/tagging/use-tags.ts
Normal 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
1
src/core/theme/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ThemeProvider } from './theme-provider';
|
||||
20
src/core/theme/theme-provider.tsx
Normal file
20
src/core/theme/theme-provider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
168
src/modules/address-book/components/address-book-module.tsx
Normal file
168
src/modules/address-book/components/address-book-module.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/modules/address-book/config.ts
Normal file
17
src/modules/address-book/config.ts
Normal 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'],
|
||||
};
|
||||
73
src/modules/address-book/hooks/use-contacts.ts
Normal file
73
src/modules/address-book/hooks/use-contacts.ts
Normal 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 };
|
||||
}
|
||||
3
src/modules/address-book/index.ts
Normal file
3
src/modules/address-book/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { addressBookConfig } from './config';
|
||||
export { AddressBookModule } from './components/address-book-module';
|
||||
export type { AddressContact, ContactType } from './types';
|
||||
17
src/modules/address-book/types.ts
Normal file
17
src/modules/address-book/types.ts
Normal 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;
|
||||
}
|
||||
164
src/modules/ai-chat/components/ai-chat-module.tsx
Normal file
164
src/modules/ai-chat/components/ai-chat-module.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/modules/ai-chat/config.ts
Normal file
17
src/modules/ai-chat/config.ts
Normal 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'],
|
||||
};
|
||||
93
src/modules/ai-chat/hooks/use-chat.ts
Normal file
93
src/modules/ai-chat/hooks/use-chat.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
3
src/modules/ai-chat/index.ts
Normal file
3
src/modules/ai-chat/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { aiChatConfig } from './config';
|
||||
export { AiChatModule } from './components/ai-chat-module';
|
||||
export type { ChatMessage, ChatRole, ChatSession } from './types';
|
||||
15
src/modules/ai-chat/types.ts
Normal file
15
src/modules/ai-chat/types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export type ChatRole = 'user' | 'assistant';
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: ChatMessage[];
|
||||
createdAt: string;
|
||||
}
|
||||
1
src/modules/dashboard/index.ts
Normal file
1
src/modules/dashboard/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type { DashboardWidget, DashboardWidgetType } from './types';
|
||||
9
src/modules/dashboard/types.ts
Normal file
9
src/modules/dashboard/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type DashboardWidgetType = 'stat' | 'recent' | 'quick-action' | 'external-link';
|
||||
|
||||
export interface DashboardWidget {
|
||||
id: string;
|
||||
title: string;
|
||||
type: DashboardWidgetType;
|
||||
moduleId?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, Search, PenTool, Stamp, Type } 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 { 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 { CompanyId } from '@/core/auth/types';
|
||||
import type { SignatureAsset, SignatureAssetType } from '../types';
|
||||
import { useSignatures } from '../hooks/use-signatures';
|
||||
|
||||
const TYPE_LABELS: Record<SignatureAssetType, string> = {
|
||||
signature: 'Semnătură', stamp: 'Ștampilă', initials: 'Inițiale',
|
||||
};
|
||||
|
||||
const TYPE_ICONS: Record<SignatureAssetType, typeof PenTool> = {
|
||||
signature: PenTool, stamp: Stamp, initials: Type,
|
||||
};
|
||||
|
||||
type ViewMode = 'list' | 'add' | 'edit';
|
||||
|
||||
export function DigitalSignaturesModule() {
|
||||
const { assets, allAssets, loading, filters, updateFilter, addAsset, updateAsset, removeAsset } = useSignatures();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [editingAsset, setEditingAsset] = useState<SignatureAsset | null>(null);
|
||||
|
||||
const handleSubmit = async (data: Omit<SignatureAsset, 'id' | 'createdAt'>) => {
|
||||
if (viewMode === 'edit' && editingAsset) {
|
||||
await updateAsset(editingAsset.id, data);
|
||||
} else {
|
||||
await addAsset(data);
|
||||
}
|
||||
setViewMode('list');
|
||||
setEditingAsset(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">{allAssets.length}</p></CardContent></Card>
|
||||
{(Object.keys(TYPE_LABELS) as SignatureAssetType[]).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">{allAssets.filter((a) => a.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ă..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
|
||||
</div>
|
||||
<Select value={filters.type} onValueChange={(v) => updateFilter('type', v as SignatureAssetType | 'all')}>
|
||||
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toate tipurile</SelectItem>
|
||||
{(Object.keys(TYPE_LABELS) as SignatureAssetType[]).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>
|
||||
) : assets.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Niciun element găsit. Adaugă o semnătură, ștampilă sau inițiale.</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{assets.map((asset) => {
|
||||
const Icon = TYPE_ICONS[asset.type];
|
||||
return (
|
||||
<Card key={asset.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={() => { setEditingAsset(asset); setViewMode('edit'); }}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeAsset(asset.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg border bg-muted/30">
|
||||
{asset.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={asset.imageUrl} alt={asset.label} className="max-h-10 max-w-10 object-contain" />
|
||||
) : (
|
||||
<Icon className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{asset.label}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge variant="outline" className="text-[10px]">{TYPE_LABELS[asset.type]}</Badge>
|
||||
<span className="text-xs text-muted-foreground">{asset.owner}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(viewMode === 'add' || viewMode === 'edit') && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare' : 'Element nou'}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<AssetForm initial={editingAsset ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingAsset(null); }} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetForm({ initial, onSubmit, onCancel }: {
|
||||
initial?: SignatureAsset;
|
||||
onSubmit: (data: Omit<SignatureAsset, 'id' | 'createdAt'>) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [label, setLabel] = useState(initial?.label ?? '');
|
||||
const [type, setType] = useState<SignatureAssetType>(initial?.type ?? 'signature');
|
||||
const [imageUrl, setImageUrl] = useState(initial?.imageUrl ?? '');
|
||||
const [owner, setOwner] = useState(initial?.owner ?? '');
|
||||
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ label, type, imageUrl, owner, company, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all' }); }} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div><Label>Denumire</Label><Input value={label} onChange={(e) => setLabel(e.target.value)} className="mt-1" required /></div>
|
||||
<div><Label>Tip</Label>
|
||||
<Select value={type} onValueChange={(v) => setType(v as SignatureAssetType)}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="signature">Semnătură</SelectItem>
|
||||
<SelectItem value="stamp">Ștampilă</SelectItem>
|
||||
<SelectItem value="initials">Inițiale</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div><Label>Proprietar</Label><Input value={owner} onChange={(e) => setOwner(e.target.value)} className="mt-1" /></div>
|
||||
<div><Label>Companie</Label>
|
||||
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="beletage">Beletage</SelectItem>
|
||||
<SelectItem value="urban-switch">Urban Switch</SelectItem>
|
||||
<SelectItem value="studii-de-teren">Studii de Teren</SelectItem>
|
||||
<SelectItem value="group">Grup</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>URL imagine</Label>
|
||||
<Input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} className="mt-1" placeholder="https://... sau data:image/png;base64,..." />
|
||||
<p className="mt-1 text-xs text-muted-foreground">URL către imaginea semnăturii/ștampilei. Suportă URL-uri externe sau base64.</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
17
src/modules/digital-signatures/config.ts
Normal file
17
src/modules/digital-signatures/config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ModuleConfig } from '@/core/module-registry/types';
|
||||
|
||||
export const digitalSignaturesConfig: ModuleConfig = {
|
||||
id: 'digital-signatures',
|
||||
name: 'Semnături și Ștampile',
|
||||
description: 'Gestionare semnături digitale, ștampile și inițiale pentru documente',
|
||||
icon: 'pen-tool',
|
||||
route: '/digital-signatures',
|
||||
category: 'management',
|
||||
featureFlag: 'module.digital-signatures',
|
||||
visibility: 'all',
|
||||
version: '0.1.0',
|
||||
dependencies: [],
|
||||
storageNamespace: 'digital-signatures',
|
||||
navOrder: 30,
|
||||
tags: ['semnături', 'ștampile', 'documente'],
|
||||
};
|
||||
73
src/modules/digital-signatures/hooks/use-signatures.ts
Normal file
73
src/modules/digital-signatures/hooks/use-signatures.ts
Normal 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 { SignatureAsset, SignatureAssetType } from '../types';
|
||||
|
||||
const PREFIX = 'sig:';
|
||||
|
||||
export interface SignatureFilters {
|
||||
search: string;
|
||||
type: SignatureAssetType | 'all';
|
||||
}
|
||||
|
||||
export function useSignatures() {
|
||||
const storage = useStorage('digital-signatures');
|
||||
const [assets, setAssets] = useState<SignatureAsset[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState<SignatureFilters>({ search: '', type: 'all' });
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const keys = await storage.list();
|
||||
const results: SignatureAsset[] = [];
|
||||
for (const key of keys) {
|
||||
if (key.startsWith(PREFIX)) {
|
||||
const item = await storage.get<SignatureAsset>(key);
|
||||
if (item) results.push(item);
|
||||
}
|
||||
}
|
||||
results.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
setAssets(results);
|
||||
setLoading(false);
|
||||
}, [storage]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
const addAsset = useCallback(async (data: Omit<SignatureAsset, 'id' | 'createdAt'>) => {
|
||||
const asset: SignatureAsset = { ...data, id: uuid(), createdAt: new Date().toISOString() };
|
||||
await storage.set(`${PREFIX}${asset.id}`, asset);
|
||||
await refresh();
|
||||
return asset;
|
||||
}, [storage, refresh]);
|
||||
|
||||
const updateAsset = useCallback(async (id: string, updates: Partial<SignatureAsset>) => {
|
||||
const existing = assets.find((a) => a.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, assets]);
|
||||
|
||||
const removeAsset = useCallback(async (id: string) => {
|
||||
await storage.delete(`${PREFIX}${id}`);
|
||||
await refresh();
|
||||
}, [storage, refresh]);
|
||||
|
||||
const updateFilter = useCallback(<K extends keyof SignatureFilters>(key: K, value: SignatureFilters[K]) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
const filteredAssets = assets.filter((a) => {
|
||||
if (filters.type !== 'all' && a.type !== filters.type) return false;
|
||||
if (filters.search) {
|
||||
const q = filters.search.toLowerCase();
|
||||
return a.label.toLowerCase().includes(q) || a.owner.toLowerCase().includes(q);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return { assets: filteredAssets, allAssets: assets, loading, filters, updateFilter, addAsset, updateAsset, removeAsset, refresh };
|
||||
}
|
||||
3
src/modules/digital-signatures/index.ts
Normal file
3
src/modules/digital-signatures/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { digitalSignaturesConfig } from './config';
|
||||
export { DigitalSignaturesModule } from './components/digital-signatures-module';
|
||||
export type { SignatureAsset, SignatureAssetType } from './types';
|
||||
16
src/modules/digital-signatures/types.ts
Normal file
16
src/modules/digital-signatures/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Visibility } from '@/core/module-registry/types';
|
||||
import type { CompanyId } from '@/core/auth/types';
|
||||
|
||||
export type SignatureAssetType = 'signature' | 'stamp' | 'initials';
|
||||
|
||||
export interface SignatureAsset {
|
||||
id: string;
|
||||
label: string;
|
||||
type: SignatureAssetType;
|
||||
imageUrl: string;
|
||||
owner: string;
|
||||
company: CompanyId;
|
||||
tags: string[];
|
||||
visibility: Visibility;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useSignatureConfig } from '../hooks/use-signature-config';
|
||||
import { useSavedSignatures } from '../hooks/use-saved-signatures';
|
||||
import { SignatureConfigurator } from './signature-configurator';
|
||||
import { SignaturePreview } from './signature-preview';
|
||||
import { SavedSignaturesPanel } from './saved-signatures-panel';
|
||||
import { Separator } from '@/shared/components/ui/separator';
|
||||
import { Button } from '@/shared/components/ui/button';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
|
||||
export function EmailSignatureModule() {
|
||||
const {
|
||||
config, updateField, updateColor, updateLayout,
|
||||
setVariant, setCompany, resetToDefaults, loadConfig,
|
||||
} = useSignatureConfig();
|
||||
|
||||
const { saved, loading, save, remove } = useSavedSignatures();
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
|
||||
{/* Left panel — configurator */}
|
||||
<div className="space-y-6 overflow-y-auto lg:max-h-[calc(100vh-10rem)] lg:pr-2">
|
||||
<SignatureConfigurator
|
||||
config={config}
|
||||
onUpdateField={updateField}
|
||||
onUpdateColor={updateColor}
|
||||
onUpdateLayout={updateLayout}
|
||||
onSetVariant={setVariant}
|
||||
onSetCompany={setCompany}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SavedSignaturesPanel
|
||||
saved={saved}
|
||||
loading={loading}
|
||||
onSave={async (label, cfg) => { await save(label, cfg); }}
|
||||
onLoad={loadConfig}
|
||||
onRemove={remove}
|
||||
currentConfig={config}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button variant="outline" size="sm" onClick={resetToDefaults} className="w-full">
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
Resetare la valorile implicite
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Right panel — preview */}
|
||||
<div>
|
||||
<SignaturePreview config={config} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Trash2, Upload, Save } from 'lucide-react';
|
||||
import { Button } from '@/shared/components/ui/button';
|
||||
import { Input } from '@/shared/components/ui/input';
|
||||
import type { SavedSignature, SignatureConfig } from '../types';
|
||||
|
||||
interface SavedSignaturesPanelProps {
|
||||
saved: SavedSignature[];
|
||||
loading: boolean;
|
||||
onSave: (label: string, config: SignatureConfig) => Promise<void>;
|
||||
onLoad: (config: SignatureConfig) => void;
|
||||
onRemove: (id: string) => Promise<void>;
|
||||
currentConfig: SignatureConfig;
|
||||
}
|
||||
|
||||
export function SavedSignaturesPanel({
|
||||
saved, loading, onSave, onLoad, onRemove, currentConfig,
|
||||
}: SavedSignaturesPanelProps) {
|
||||
const [label, setLabel] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!label.trim()) return;
|
||||
setSaving(true);
|
||||
await onSave(label.trim(), currentConfig);
|
||||
setLabel('');
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">Semnături salvate</h3>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Nume semnătură..."
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Button size="sm" onClick={handleSave} disabled={saving || !label.trim()}>
|
||||
<Save className="mr-1 h-3.5 w-3.5" />
|
||||
Salvează
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-xs text-muted-foreground">Se încarcă...</p>
|
||||
) : saved.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Nicio semnătură salvată.</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{saved.map((s) => (
|
||||
<li key={s.id} className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm font-medium">{s.label}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{s.config.company} · {s.config.name || 'fără nume'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onLoad(s.config)}>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => onRemove(s.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import type { CompanyId } from '@/core/auth/types';
|
||||
import type { SignatureConfig, SignatureColors, SignatureLayout, SignatureVariant } from '../types';
|
||||
import { COMPANY_BRANDING } from '../services/company-branding';
|
||||
import { Input } from '@/shared/components/ui/input';
|
||||
import { Label } from '@/shared/components/ui/label';
|
||||
import { Switch } from '@/shared/components/ui/switch';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
||||
import { Separator } from '@/shared/components/ui/separator';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
interface SignatureConfiguratorProps {
|
||||
config: SignatureConfig;
|
||||
onUpdateField: <K extends keyof SignatureConfig>(key: K, value: SignatureConfig[K]) => void;
|
||||
onUpdateColor: (key: keyof SignatureColors, value: string) => void;
|
||||
onUpdateLayout: (key: keyof SignatureLayout, value: number) => void;
|
||||
onSetVariant: (variant: SignatureVariant) => void;
|
||||
onSetCompany: (company: CompanyId) => void;
|
||||
}
|
||||
|
||||
const COLOR_PALETTE: Record<string, string> = {
|
||||
verde: '#22B5AB',
|
||||
griInchis: '#54504F',
|
||||
griDeschis: '#A7A9AA',
|
||||
negru: '#323232',
|
||||
};
|
||||
|
||||
const COLOR_LABELS: Record<keyof SignatureColors, string> = {
|
||||
prefix: 'Titulatură',
|
||||
name: 'Nume',
|
||||
title: 'Funcție',
|
||||
address: 'Adresă',
|
||||
phone: 'Telefon',
|
||||
website: 'Website',
|
||||
motto: 'Motto',
|
||||
};
|
||||
|
||||
const LAYOUT_CONTROLS: { key: keyof SignatureLayout; label: string; min: number; max: number }[] = [
|
||||
{ key: 'greenLineWidth', label: 'Lungime linie accent', min: 50, max: 300 },
|
||||
{ key: 'sectionSpacing', label: 'Spațiere secțiuni', min: 0, max: 30 },
|
||||
{ key: 'logoSpacing', label: 'Spațiere logo', min: 0, max: 30 },
|
||||
{ key: 'titleSpacing', label: 'Spațiere funcție', min: 0, max: 20 },
|
||||
{ key: 'gutterWidth', label: 'Aliniere contact', min: 0, max: 150 },
|
||||
{ key: 'iconTextSpacing', label: 'Spațiu icon-text', min: -10, max: 30 },
|
||||
{ key: 'iconVerticalOffset', label: 'Aliniere verticală iconițe', min: -10, max: 10 },
|
||||
{ key: 'mottoSpacing', label: 'Spațiere motto', min: 0, max: 20 },
|
||||
];
|
||||
|
||||
export function SignatureConfigurator({
|
||||
config, onUpdateField, onUpdateColor, onUpdateLayout, onSetVariant, onSetCompany,
|
||||
}: SignatureConfiguratorProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Company selector */}
|
||||
<div>
|
||||
<Label>Companie</Label>
|
||||
<Select value={config.company} onValueChange={(v) => onSetCompany(v as CompanyId)}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(COMPANY_BRANDING).map((b) => (
|
||||
<SelectItem key={b.id} value={b.id}>{b.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Personal data */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">Date personale</h3>
|
||||
<div>
|
||||
<Label htmlFor="sig-prefix">Titulatură (prefix)</Label>
|
||||
<Input id="sig-prefix" value={config.prefix} onChange={(e) => onUpdateField('prefix', e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sig-name">Nume și Prenume</Label>
|
||||
<Input id="sig-name" value={config.name} onChange={(e) => onUpdateField('name', e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sig-title">Funcția</Label>
|
||||
<Input id="sig-title" value={config.title} onChange={(e) => onUpdateField('title', e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sig-phone">Telefon (format 07xxxxxxxx)</Label>
|
||||
<Input id="sig-phone" type="tel" value={config.phone} onChange={(e) => onUpdateField('phone', e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Variant */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">Variantă</h3>
|
||||
<Select value={config.variant} onValueChange={(v) => onSetVariant(v as SignatureVariant)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="full">Completă (logo + adresă + motto)</SelectItem>
|
||||
<SelectItem value="reply">Simplă (fără logo/adresă)</SelectItem>
|
||||
<SelectItem value="minimal">Super-simplă (doar nume/telefon)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={config.useSvg} onCheckedChange={(v) => onUpdateField('useSvg', v)} id="svg-toggle" />
|
||||
<Label htmlFor="svg-toggle" className="cursor-pointer text-sm">Imagini SVG (calitate maximă)</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Colors */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">Culori text</h3>
|
||||
{(Object.keys(COLOR_LABELS) as (keyof SignatureColors)[]).map((colorKey) => (
|
||||
<div key={colorKey} className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">{COLOR_LABELS[colorKey]}</span>
|
||||
<div className="flex gap-1.5">
|
||||
{Object.values(COLOR_PALETTE).map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => onUpdateColor(colorKey, color)}
|
||||
className={cn(
|
||||
'h-6 w-6 rounded-full border-2 transition-all',
|
||||
config.colors[colorKey] === color
|
||||
? 'border-primary scale-110 ring-2 ring-primary/30'
|
||||
: 'border-transparent hover:scale-105'
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Layout sliders */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">Stil & Aranjare</h3>
|
||||
{LAYOUT_CONTROLS.map(({ key, label, min, max }) => (
|
||||
<div key={key}>
|
||||
<div className="flex justify-between text-sm">
|
||||
<Label>{label}</Label>
|
||||
<span className="text-muted-foreground">{config.layout[key]}px</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
value={config.layout[key]}
|
||||
onChange={(e) => onUpdateLayout(key, parseInt(e.target.value, 10))}
|
||||
className="mt-1 w-full accent-primary"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
src/modules/email-signature/components/signature-preview.tsx
Normal file
67
src/modules/email-signature/components/signature-preview.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { Download, ZoomIn, ZoomOut, Copy } from 'lucide-react';
|
||||
import { Button } from '@/shared/components/ui/button';
|
||||
import type { SignatureConfig } from '../types';
|
||||
import { generateSignatureHtml, downloadSignatureHtml } from '../services/signature-builder';
|
||||
|
||||
interface SignaturePreviewProps {
|
||||
config: SignatureConfig;
|
||||
}
|
||||
|
||||
export function SignaturePreview({ config }: SignaturePreviewProps) {
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const html = useMemo(() => generateSignatureHtml(config), [config]);
|
||||
|
||||
const handleDownload = () => {
|
||||
const filename = `semnatura-${config.company}-${config.name.toLowerCase().replace(/\s+/g, '-') || 'email'}.html`;
|
||||
downloadSignatureHtml(html, filename);
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(html);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
};
|
||||
|
||||
const toggleZoom = () => setZoom((z) => (z === 1 ? 2 : 1));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Previzualizare</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={toggleZoom}>
|
||||
{zoom === 1 ? <ZoomIn className="mr-1 h-4 w-4" /> : <ZoomOut className="mr-1 h-4 w-4" />}
|
||||
{zoom === 1 ? '200%' : '100%'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleCopy}>
|
||||
<Copy className="mr-1 h-4 w-4" />
|
||||
{copied ? 'Copiat!' : 'Copiază HTML'}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleDownload}>
|
||||
<Download className="mr-1 h-4 w-4" />
|
||||
Descarcă HTML
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto rounded-lg border bg-white p-6">
|
||||
<div
|
||||
ref={previewRef}
|
||||
style={{ transform: `scale(${zoom})`, transformOrigin: 'top left' }}
|
||||
className="transition-transform"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
src/modules/email-signature/config.ts
Normal file
17
src/modules/email-signature/config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ModuleConfig } from '@/core/module-registry/types';
|
||||
|
||||
export const emailSignatureConfig: ModuleConfig = {
|
||||
id: 'email-signature',
|
||||
name: 'Generator Semnătură Email',
|
||||
description: 'Generator de semnături email profesionale cu suport multi-firmă și variante de layout',
|
||||
icon: 'mail',
|
||||
route: '/email-signature',
|
||||
category: 'generators',
|
||||
featureFlag: 'module.email-signature',
|
||||
visibility: 'all',
|
||||
version: '0.1.0',
|
||||
dependencies: [],
|
||||
storageNamespace: 'email-signature',
|
||||
navOrder: 20,
|
||||
tags: ['email', 'semnătură', 'generator'],
|
||||
};
|
||||
51
src/modules/email-signature/hooks/use-saved-signatures.ts
Normal file
51
src/modules/email-signature/hooks/use-saved-signatures.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useStorage } from '@/core/storage';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { SignatureConfig, SavedSignature } from '../types';
|
||||
|
||||
export function useSavedSignatures() {
|
||||
const storage = useStorage('email-signature');
|
||||
const [saved, setSaved] = useState<SavedSignature[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const keys = await storage.list();
|
||||
const items: SavedSignature[] = [];
|
||||
for (const key of keys) {
|
||||
if (key.startsWith('sig:')) {
|
||||
const item = await storage.get<SavedSignature>(key);
|
||||
if (item) items.push(item);
|
||||
}
|
||||
}
|
||||
items.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
setSaved(items);
|
||||
setLoading(false);
|
||||
}, [storage]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
const save = useCallback(async (label: string, config: SignatureConfig) => {
|
||||
const now = new Date().toISOString();
|
||||
const entry: SavedSignature = {
|
||||
id: uuid(),
|
||||
label,
|
||||
config,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await storage.set(`sig:${entry.id}`, entry);
|
||||
await refresh();
|
||||
return entry;
|
||||
}, [storage, refresh]);
|
||||
|
||||
const remove = useCallback(async (id: string) => {
|
||||
await storage.delete(`sig:${id}`);
|
||||
await refresh();
|
||||
}, [storage, refresh]);
|
||||
|
||||
return { saved, loading, save, remove, refresh };
|
||||
}
|
||||
89
src/modules/email-signature/hooks/use-signature-config.ts
Normal file
89
src/modules/email-signature/hooks/use-signature-config.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import type { CompanyId } from '@/core/auth/types';
|
||||
import type { SignatureConfig, SignatureVariant, SignatureColors, SignatureLayout } from '../types';
|
||||
import { getBranding } from '../services/company-branding';
|
||||
|
||||
const DEFAULT_LAYOUT: SignatureLayout = {
|
||||
greenLineWidth: 97,
|
||||
gutterWidth: 13,
|
||||
iconTextSpacing: 5,
|
||||
iconVerticalOffset: 1,
|
||||
mottoSpacing: 3,
|
||||
sectionSpacing: 10,
|
||||
titleSpacing: 2,
|
||||
logoSpacing: 10,
|
||||
};
|
||||
|
||||
function createDefaultConfig(company: CompanyId = 'beletage'): SignatureConfig {
|
||||
const branding = getBranding(company);
|
||||
return {
|
||||
prefix: 'arh.',
|
||||
name: '',
|
||||
title: '',
|
||||
phone: '',
|
||||
company,
|
||||
colors: { ...branding.defaultColors },
|
||||
layout: { ...DEFAULT_LAYOUT },
|
||||
variant: 'full',
|
||||
useSvg: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSignatureConfig(initialCompany: CompanyId = 'beletage') {
|
||||
const [config, setConfig] = useState<SignatureConfig>(() => createDefaultConfig(initialCompany));
|
||||
|
||||
const updateField = useCallback(<K extends keyof SignatureConfig>(
|
||||
key: K,
|
||||
value: SignatureConfig[K]
|
||||
) => {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
const updateColor = useCallback((key: keyof SignatureColors, value: string) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
colors: { ...prev.colors, [key]: value },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const updateLayout = useCallback((key: keyof SignatureLayout, value: number) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
layout: { ...prev.layout, [key]: value },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setVariant = useCallback((variant: SignatureVariant) => {
|
||||
setConfig((prev) => ({ ...prev, variant }));
|
||||
}, []);
|
||||
|
||||
const setCompany = useCallback((company: CompanyId) => {
|
||||
const branding = getBranding(company);
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
company,
|
||||
colors: { ...branding.defaultColors },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const resetToDefaults = useCallback(() => {
|
||||
setConfig(createDefaultConfig(config.company));
|
||||
}, [config.company]);
|
||||
|
||||
const loadConfig = useCallback((loaded: SignatureConfig) => {
|
||||
setConfig(loaded);
|
||||
}, []);
|
||||
|
||||
return useMemo(() => ({
|
||||
config,
|
||||
updateField,
|
||||
updateColor,
|
||||
updateLayout,
|
||||
setVariant,
|
||||
setCompany,
|
||||
resetToDefaults,
|
||||
loadConfig,
|
||||
}), [config, updateField, updateColor, updateLayout, setVariant, setCompany, resetToDefaults, loadConfig]);
|
||||
}
|
||||
3
src/modules/email-signature/index.ts
Normal file
3
src/modules/email-signature/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { emailSignatureConfig } from './config';
|
||||
export { EmailSignatureModule } from './components/email-signature-module';
|
||||
export type { SignatureConfig, SignatureVariant, SignatureColors, SignatureLayout, SavedSignature } from './types';
|
||||
114
src/modules/email-signature/services/company-branding.ts
Normal file
114
src/modules/email-signature/services/company-branding.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { CompanyId } from '@/core/auth/types';
|
||||
import type { CompanyBranding, SignatureColors } from '../types';
|
||||
|
||||
const BELETAGE_COLORS: SignatureColors = {
|
||||
prefix: '#54504F',
|
||||
name: '#54504F',
|
||||
title: '#A7A9AA',
|
||||
address: '#A7A9AA',
|
||||
phone: '#54504F',
|
||||
website: '#54504F',
|
||||
motto: '#22B5AB',
|
||||
};
|
||||
|
||||
const URBAN_SWITCH_COLORS: SignatureColors = {
|
||||
prefix: '#3B3B3B',
|
||||
name: '#3B3B3B',
|
||||
title: '#8B8B8B',
|
||||
address: '#8B8B8B',
|
||||
phone: '#3B3B3B',
|
||||
website: '#3B3B3B',
|
||||
motto: '#6366f1',
|
||||
};
|
||||
|
||||
const STUDII_COLORS: SignatureColors = {
|
||||
prefix: '#3B3B3B',
|
||||
name: '#3B3B3B',
|
||||
title: '#8B8B8B',
|
||||
address: '#8B8B8B',
|
||||
phone: '#3B3B3B',
|
||||
website: '#3B3B3B',
|
||||
motto: '#f59e0b',
|
||||
};
|
||||
|
||||
export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
|
||||
beletage: {
|
||||
id: 'beletage',
|
||||
name: 'Beletage SRL',
|
||||
accent: '#22B5AB',
|
||||
logo: {
|
||||
png: 'https://beletage.ro/img/Semnatura-Logo.png',
|
||||
svg: 'https://beletage.ro/img/Logo-Beletage.svg',
|
||||
},
|
||||
slashGrey: {
|
||||
png: 'https://beletage.ro/img/Grey-slash.png',
|
||||
svg: 'https://beletage.ro/img/Grey-slash.svg',
|
||||
},
|
||||
slashAccent: {
|
||||
png: 'https://beletage.ro/img/Green-slash.png',
|
||||
svg: 'https://beletage.ro/img/Green-slash.svg',
|
||||
},
|
||||
address: ['str. Unirii, nr. 3, ap. 26', 'Cluj-Napoca, Cluj 400417', 'România'],
|
||||
website: 'www.beletage.ro',
|
||||
motto: 'we make complex simple',
|
||||
defaultColors: BELETAGE_COLORS,
|
||||
},
|
||||
'urban-switch': {
|
||||
id: 'urban-switch',
|
||||
name: 'Urban Switch SRL',
|
||||
accent: '#6366f1',
|
||||
logo: {
|
||||
png: '',
|
||||
svg: '',
|
||||
},
|
||||
slashGrey: {
|
||||
png: 'https://beletage.ro/img/Grey-slash.png',
|
||||
svg: 'https://beletage.ro/img/Grey-slash.svg',
|
||||
},
|
||||
slashAccent: {
|
||||
png: '',
|
||||
svg: '',
|
||||
},
|
||||
address: ['Cluj-Napoca', 'România'],
|
||||
website: '',
|
||||
motto: '',
|
||||
defaultColors: URBAN_SWITCH_COLORS,
|
||||
},
|
||||
'studii-de-teren': {
|
||||
id: 'studii-de-teren',
|
||||
name: 'Studii de Teren SRL',
|
||||
accent: '#f59e0b',
|
||||
logo: {
|
||||
png: '',
|
||||
svg: '',
|
||||
},
|
||||
slashGrey: {
|
||||
png: 'https://beletage.ro/img/Grey-slash.png',
|
||||
svg: 'https://beletage.ro/img/Grey-slash.svg',
|
||||
},
|
||||
slashAccent: {
|
||||
png: '',
|
||||
svg: '',
|
||||
},
|
||||
address: ['Cluj-Napoca', 'România'],
|
||||
website: '',
|
||||
motto: '',
|
||||
defaultColors: STUDII_COLORS,
|
||||
},
|
||||
group: {
|
||||
id: 'group',
|
||||
name: 'Grup Companii',
|
||||
accent: '#64748b',
|
||||
logo: { png: '', svg: '' },
|
||||
slashGrey: { png: '', svg: '' },
|
||||
slashAccent: { png: '', svg: '' },
|
||||
address: ['Cluj-Napoca', 'România'],
|
||||
website: '',
|
||||
motto: '',
|
||||
defaultColors: BELETAGE_COLORS,
|
||||
},
|
||||
};
|
||||
|
||||
export function getBranding(company: CompanyId): CompanyBranding {
|
||||
return COMPANY_BRANDING[company];
|
||||
}
|
||||
124
src/modules/email-signature/services/signature-builder.ts
Normal file
124
src/modules/email-signature/services/signature-builder.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { SignatureConfig, CompanyBranding } from '../types';
|
||||
import { getBranding } from './company-branding';
|
||||
|
||||
export function formatPhone(raw: string): { display: string; link: string } {
|
||||
const clean = raw.replace(/\s/g, '');
|
||||
if (clean.length === 10 && clean.startsWith('07')) {
|
||||
return {
|
||||
display: `+40 ${clean.substring(1, 4)} ${clean.substring(4, 7)} ${clean.substring(7, 10)}`,
|
||||
link: `tel:+40${clean.substring(1)}`,
|
||||
};
|
||||
}
|
||||
return { display: raw, link: `tel:${clean}` };
|
||||
}
|
||||
|
||||
export function generateSignatureHtml(config: SignatureConfig): string {
|
||||
const branding = getBranding(config.company);
|
||||
const { display: phone, link: phoneLink } = formatPhone(config.phone);
|
||||
const images = config.useSvg
|
||||
? { logo: branding.logo.svg, greySlash: branding.slashGrey.svg, accentSlash: branding.slashAccent.svg }
|
||||
: { logo: branding.logo.png, greySlash: branding.slashGrey.png, accentSlash: branding.slashAccent.png };
|
||||
|
||||
const {
|
||||
greenLineWidth, gutterWidth, iconTextSpacing, iconVerticalOffset,
|
||||
mottoSpacing, sectionSpacing, titleSpacing, logoSpacing,
|
||||
} = config.layout;
|
||||
const colors = config.colors;
|
||||
|
||||
const isReply = config.variant === 'reply' || config.variant === 'minimal';
|
||||
const isMinimal = config.variant === 'minimal';
|
||||
|
||||
const hide = 'mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;';
|
||||
const hideTitle = isReply ? hide : '';
|
||||
const hideLogo = isReply ? hide : '';
|
||||
const hideBottom = isMinimal ? hide : '';
|
||||
const hidePhoneIcon = isMinimal ? hide : '';
|
||||
|
||||
const spacerWidth = Math.max(0, iconTextSpacing);
|
||||
const textPaddingLeft = Math.max(0, -iconTextSpacing);
|
||||
|
||||
const prefixHtml = config.prefix
|
||||
? `<span style="font-size:13px; color:${colors.prefix};">${esc(config.prefix)} </span>`
|
||||
: '';
|
||||
|
||||
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;">${esc(config.name)}</span></td></tr>
|
||||
<tr style="${hideTitle}"><td style="padding:0 0 8px 0;"><span style="font-size:12px; color:${colors.title};">${esc(config.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="${branding.accent}" 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="${hideLogo}"><td style="padding:${logoSpacing}px 0 ${logoSpacing + 2}px 0;">
|
||||
${images.logo ? `<a href="https://${branding.website}" style="text-decoration:none; border:0;">
|
||||
<img src="${images.logo}" alt="${esc(branding.name)}" style="display:block; border:0; height:24px; width:162px;" height="24" width="162">
|
||||
</a>` : ''}
|
||||
</td></tr>
|
||||
<tr>
|
||||
<td style="padding-top:${hideLogo ? '0' : sectionSpacing}px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540" style="font-size:13px; line-height:18px;">
|
||||
<tbody>
|
||||
<tr style="${hideLogo}">
|
||||
<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;">
|
||||
${images.greySlash ? `<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;">
|
||||
<span style="color:${colors.address}; text-decoration:none;">${branding.address.join('<br>')}</span>
|
||||
</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}">
|
||||
${images.accentSlash ? `<img src="${images.accentSlash}" alt="" width="11" height="7" style="display:block; border:0;">` : ''}
|
||||
</td>
|
||||
<td width="${isMinimal ? 0 : spacerWidth}" style="width:${isMinimal ? 0 : spacerWidth}px; font-size:0; line-height:0;"></td>
|
||||
<td style="vertical-align:top; padding:8px 0 0 ${isMinimal ? 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>
|
||||
${branding.website ? `<tr style="${hideBottom}"><td style="padding:${sectionSpacing}px 0 ${mottoSpacing}px 0;"><a href="https://${branding.website}" style="color:${colors.website}; text-decoration:none;"><span style="color:${colors.website}; text-decoration:none;">${branding.website}</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="${branding.accent}" 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>
|
||||
${branding.motto ? `<tr style="${hideBottom}"><td style="padding:${mottoSpacing}px 0 0 0;"><span style="font-size:12px; color:${colors.motto}; font-style:italic;">${esc(branding.motto)}</span></td></tr>` : ''}
|
||||
</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function esc(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export function downloadSignatureHtml(html: string, filename: string): void {
|
||||
const blob = new Blob([html], { type: 'text/html' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
59
src/modules/email-signature/types.ts
Normal file
59
src/modules/email-signature/types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { CompanyId } from '@/core/auth/types';
|
||||
|
||||
export type SignatureVariant = 'full' | 'reply' | 'minimal';
|
||||
|
||||
export interface SignatureColors {
|
||||
prefix: string;
|
||||
name: string;
|
||||
title: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
website: string;
|
||||
motto: string;
|
||||
}
|
||||
|
||||
export interface SignatureLayout {
|
||||
greenLineWidth: number;
|
||||
gutterWidth: number;
|
||||
iconTextSpacing: number;
|
||||
iconVerticalOffset: number;
|
||||
mottoSpacing: number;
|
||||
sectionSpacing: number;
|
||||
titleSpacing: number;
|
||||
logoSpacing: number;
|
||||
}
|
||||
|
||||
export interface CompanyBranding {
|
||||
id: CompanyId;
|
||||
name: string;
|
||||
accent: string;
|
||||
logo: { png: string; svg: string };
|
||||
slashGrey: { png: string; svg: string };
|
||||
slashAccent: { png: string; svg: string };
|
||||
address: string[];
|
||||
website: string;
|
||||
motto: string;
|
||||
defaultColors: SignatureColors;
|
||||
}
|
||||
|
||||
export interface SignatureConfig {
|
||||
id?: string;
|
||||
label?: string;
|
||||
prefix: string;
|
||||
name: string;
|
||||
title: string;
|
||||
phone: string;
|
||||
company: CompanyId;
|
||||
colors: SignatureColors;
|
||||
layout: SignatureLayout;
|
||||
variant: SignatureVariant;
|
||||
useSvg: boolean;
|
||||
}
|
||||
|
||||
export interface SavedSignature {
|
||||
id: string;
|
||||
label: string;
|
||||
config: SignatureConfig;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
204
src/modules/it-inventory/components/it-inventory-module.tsx
Normal file
204
src/modules/it-inventory/components/it-inventory-module.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, Search } 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 { CompanyId } from '@/core/auth/types';
|
||||
import type { InventoryItem, InventoryItemType, InventoryItemStatus } from '../types';
|
||||
import { useInventory } from '../hooks/use-inventory';
|
||||
|
||||
const TYPE_LABELS: Record<InventoryItemType, string> = {
|
||||
laptop: 'Laptop', desktop: 'Desktop', monitor: 'Monitor', printer: 'Imprimantă',
|
||||
phone: 'Telefon', tablet: 'Tabletă', network: 'Rețea', peripheral: 'Periferic', other: 'Altele',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<InventoryItemStatus, string> = {
|
||||
active: 'Activ', 'in-repair': 'În reparație', storage: 'Depozitat', decommissioned: 'Dezafectat',
|
||||
};
|
||||
|
||||
type ViewMode = 'list' | 'add' | 'edit';
|
||||
|
||||
export function ItInventoryModule() {
|
||||
const { items, allItems, loading, filters, updateFilter, addItem, updateItem, removeItem } = useInventory();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
|
||||
|
||||
const handleSubmit = async (data: Omit<InventoryItem, 'id' | 'createdAt'>) => {
|
||||
if (viewMode === 'edit' && editingItem) {
|
||||
await updateItem(editingItem.id, data);
|
||||
} else {
|
||||
await addItem(data);
|
||||
}
|
||||
setViewMode('list');
|
||||
setEditingItem(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">{allItems.length}</p></CardContent></Card>
|
||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Active</p><p className="text-2xl font-bold">{allItems.filter((i) => i.status === 'active').length}</p></CardContent></Card>
|
||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">În reparație</p><p className="text-2xl font-bold">{allItems.filter((i) => i.status === 'in-repair').length}</p></CardContent></Card>
|
||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Dezafectate</p><p className="text-2xl font-bold">{allItems.filter((i) => i.status === 'decommissioned').length}</p></CardContent></Card>
|
||||
</div>
|
||||
|
||||
{viewMode === 'list' && (
|
||||
<>
|
||||
{/* Filters */}
|
||||
<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ă..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
|
||||
</div>
|
||||
<Select value={filters.type} onValueChange={(v) => updateFilter('type', v as InventoryItemType | 'all')}>
|
||||
<SelectTrigger className="w-[140px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toate tipurile</SelectItem>
|
||||
{(Object.keys(TYPE_LABELS) as InventoryItemType[]).map((t) => (
|
||||
<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filters.status} onValueChange={(v) => updateFilter('status', v as InventoryItemStatus | 'all')}>
|
||||
<SelectTrigger className="w-[140px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toate</SelectItem>
|
||||
{(Object.keys(STATUS_LABELS) as InventoryItemStatus[]).map((s) => (
|
||||
<SelectItem key={s} value={s}>{STATUS_LABELS[s]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => setViewMode('add')} className="shrink-0">
|
||||
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Niciun echipament găsit.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b bg-muted/40">
|
||||
<th className="px-3 py-2 text-left font-medium">Nume</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Tip</th>
|
||||
<th className="px-3 py-2 text-left font-medium">S/N</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Atribuit</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Locație</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Status</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Acțiuni</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="border-b hover:bg-muted/20 transition-colors">
|
||||
<td className="px-3 py-2 font-medium">{item.name}</td>
|
||||
<td className="px-3 py-2"><Badge variant="outline">{TYPE_LABELS[item.type]}</Badge></td>
|
||||
<td className="px-3 py-2 font-mono text-xs">{item.serialNumber}</td>
|
||||
<td className="px-3 py-2">{item.assignedTo}</td>
|
||||
<td className="px-3 py-2">{item.location}</td>
|
||||
<td className="px-3 py-2"><Badge variant="secondary">{STATUS_LABELS[item.status]}</Badge></td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingItem(item); setViewMode('edit'); }}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeItem(item.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(viewMode === 'add' || viewMode === 'edit') && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare echipament' : 'Echipament nou'}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<InventoryForm
|
||||
initial={editingItem ?? undefined}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => { setViewMode('list'); setEditingItem(null); }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InventoryForm({ initial, onSubmit, onCancel }: {
|
||||
initial?: InventoryItem;
|
||||
onSubmit: (data: Omit<InventoryItem, 'id' | 'createdAt'>) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState(initial?.name ?? '');
|
||||
const [type, setType] = useState<InventoryItemType>(initial?.type ?? 'laptop');
|
||||
const [serialNumber, setSerialNumber] = useState(initial?.serialNumber ?? '');
|
||||
const [assignedTo, setAssignedTo] = useState(initial?.assignedTo ?? '');
|
||||
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
|
||||
const [location, setLocation] = useState(initial?.location ?? '');
|
||||
const [purchaseDate, setPurchaseDate] = useState(initial?.purchaseDate ?? '');
|
||||
const [status, setStatus] = useState<InventoryItemStatus>(initial?.status ?? 'active');
|
||||
const [notes, setNotes] = useState(initial?.notes ?? '');
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ name, type, serialNumber, assignedTo, company, location, purchaseDate, status, notes, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all' }); }} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div><Label>Nume echipament</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div>
|
||||
<div><Label>Tip</Label>
|
||||
<Select value={type} onValueChange={(v) => setType(v as InventoryItemType)}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{(Object.keys(TYPE_LABELS) as InventoryItemType[]).map((t) => (<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem>))}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div><Label>Număr serie</Label><Input value={serialNumber} onChange={(e) => setSerialNumber(e.target.value)} className="mt-1" /></div>
|
||||
<div><Label>Atribuit</Label><Input value={assignedTo} onChange={(e) => setAssignedTo(e.target.value)} className="mt-1" /></div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div><Label>Companie</Label>
|
||||
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="beletage">Beletage</SelectItem>
|
||||
<SelectItem value="urban-switch">Urban Switch</SelectItem>
|
||||
<SelectItem value="studii-de-teren">Studii de Teren</SelectItem>
|
||||
<SelectItem value="group">Grup</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>Locație</Label><Input value={location} onChange={(e) => setLocation(e.target.value)} className="mt-1" /></div>
|
||||
<div><Label>Data achiziție</Label><Input type="date" value={purchaseDate} onChange={(e) => setPurchaseDate(e.target.value)} className="mt-1" /></div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div><Label>Status</Label>
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as InventoryItemStatus)}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{(Object.keys(STATUS_LABELS) as InventoryItemStatus[]).map((s) => (<SelectItem key={s} value={s}>{STATUS_LABELS[s]}</SelectItem>))}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
17
src/modules/it-inventory/config.ts
Normal file
17
src/modules/it-inventory/config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ModuleConfig } from '@/core/module-registry/types';
|
||||
|
||||
export const itInventoryConfig: ModuleConfig = {
|
||||
id: 'it-inventory',
|
||||
name: 'Inventar IT',
|
||||
description: 'Evidență echipamente IT cu urmărire atribuiri și locații',
|
||||
icon: 'monitor',
|
||||
route: '/it-inventory',
|
||||
category: 'management',
|
||||
featureFlag: 'module.it-inventory',
|
||||
visibility: 'all',
|
||||
version: '0.1.0',
|
||||
dependencies: [],
|
||||
storageNamespace: 'it-inventory',
|
||||
navOrder: 31,
|
||||
tags: ['inventar', 'echipamente', 'IT'],
|
||||
};
|
||||
79
src/modules/it-inventory/hooks/use-inventory.ts
Normal file
79
src/modules/it-inventory/hooks/use-inventory.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useStorage } from '@/core/storage';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { InventoryItem, InventoryItemType, InventoryItemStatus } from '../types';
|
||||
|
||||
const PREFIX = 'item:';
|
||||
|
||||
export interface InventoryFilters {
|
||||
search: string;
|
||||
type: InventoryItemType | 'all';
|
||||
status: InventoryItemStatus | 'all';
|
||||
company: string;
|
||||
}
|
||||
|
||||
export function useInventory() {
|
||||
const storage = useStorage('it-inventory');
|
||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState<InventoryFilters>({
|
||||
search: '', type: 'all', status: 'all', company: 'all',
|
||||
});
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const keys = await storage.list();
|
||||
const results: InventoryItem[] = [];
|
||||
for (const key of keys) {
|
||||
if (key.startsWith(PREFIX)) {
|
||||
const item = await storage.get<InventoryItem>(key);
|
||||
if (item) results.push(item);
|
||||
}
|
||||
}
|
||||
results.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
setItems(results);
|
||||
setLoading(false);
|
||||
}, [storage]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
const addItem = useCallback(async (data: Omit<InventoryItem, 'id' | 'createdAt'>) => {
|
||||
const item: InventoryItem = { ...data, id: uuid(), createdAt: new Date().toISOString() };
|
||||
await storage.set(`${PREFIX}${item.id}`, item);
|
||||
await refresh();
|
||||
return item;
|
||||
}, [storage, refresh]);
|
||||
|
||||
const updateItem = useCallback(async (id: string, updates: Partial<InventoryItem>) => {
|
||||
const existing = items.find((i) => i.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, items]);
|
||||
|
||||
const removeItem = useCallback(async (id: string) => {
|
||||
await storage.delete(`${PREFIX}${id}`);
|
||||
await refresh();
|
||||
}, [storage, refresh]);
|
||||
|
||||
const updateFilter = useCallback(<K extends keyof InventoryFilters>(key: K, value: InventoryFilters[K]) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
const filteredItems = items.filter((item) => {
|
||||
if (filters.type !== 'all' && item.type !== filters.type) return false;
|
||||
if (filters.status !== 'all' && item.status !== filters.status) return false;
|
||||
if (filters.company !== 'all' && item.company !== filters.company) return false;
|
||||
if (filters.search) {
|
||||
const q = filters.search.toLowerCase();
|
||||
return item.name.toLowerCase().includes(q) || item.serialNumber.toLowerCase().includes(q) || item.assignedTo.toLowerCase().includes(q);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return { items: filteredItems, allItems: items, loading, filters, updateFilter, addItem, updateItem, removeItem, refresh };
|
||||
}
|
||||
3
src/modules/it-inventory/index.ts
Normal file
3
src/modules/it-inventory/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { itInventoryConfig } from './config';
|
||||
export { ItInventoryModule } from './components/it-inventory-module';
|
||||
export type { InventoryItem, InventoryItemType, InventoryItemStatus } from './types';
|
||||
35
src/modules/it-inventory/types.ts
Normal file
35
src/modules/it-inventory/types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Visibility } from '@/core/module-registry/types';
|
||||
import type { CompanyId } from '@/core/auth/types';
|
||||
|
||||
export type InventoryItemType =
|
||||
| 'laptop'
|
||||
| 'desktop'
|
||||
| 'monitor'
|
||||
| 'printer'
|
||||
| 'phone'
|
||||
| 'tablet'
|
||||
| 'network'
|
||||
| 'peripheral'
|
||||
| 'other';
|
||||
|
||||
export type InventoryItemStatus =
|
||||
| 'active'
|
||||
| 'in-repair'
|
||||
| 'storage'
|
||||
| 'decommissioned';
|
||||
|
||||
export interface InventoryItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: InventoryItemType;
|
||||
serialNumber: string;
|
||||
assignedTo: string;
|
||||
company: CompanyId;
|
||||
location: string;
|
||||
purchaseDate: string;
|
||||
status: InventoryItemStatus;
|
||||
tags: string[];
|
||||
notes: string;
|
||||
visibility: Visibility;
|
||||
createdAt: string;
|
||||
}
|
||||
159
src/modules/mini-utilities/components/mini-utilities-module.tsx
Normal file
159
src/modules/mini-utilities/components/mini-utilities-module.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Copy, Check, Hash, Type, Percent, Ruler } 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 { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/components/ui/tabs';
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
return (
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopy} disabled={!text}>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function TextCaseConverter() {
|
||||
const [input, setInput] = useState('');
|
||||
const upper = input.toUpperCase();
|
||||
const lower = input.toLowerCase();
|
||||
const title = input.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
const sentence = input.charAt(0).toUpperCase() + input.slice(1).toLowerCase();
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div><Label>Text sursă</Label><Textarea value={input} onChange={(e) => setInput(e.target.value)} rows={3} className="mt-1" placeholder="Introdu text..." /></div>
|
||||
{[
|
||||
{ label: 'UPPERCASE', value: upper },
|
||||
{ label: 'lowercase', value: lower },
|
||||
{ label: 'Title Case', value: title },
|
||||
{ label: 'Sentence case', value: sentence },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="flex items-center gap-2">
|
||||
<code className="flex-1 truncate rounded border bg-muted/30 px-2 py-1 text-xs">{value || '—'}</code>
|
||||
<span className="w-24 text-xs text-muted-foreground">{label}</span>
|
||||
<CopyButton text={value} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CharacterCounter() {
|
||||
const [input, setInput] = useState('');
|
||||
const chars = input.length;
|
||||
const charsNoSpaces = input.replace(/\s/g, '').length;
|
||||
const words = input.trim() ? input.trim().split(/\s+/).length : 0;
|
||||
const lines = input ? input.split('\n').length : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div><Label>Text</Label><Textarea value={input} onChange={(e) => setInput(e.target.value)} rows={5} className="mt-1" placeholder="Introdu text..." /></div>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Card><CardContent className="p-3"><p className="text-xs text-muted-foreground">Caractere</p><p className="text-xl font-bold">{chars}</p></CardContent></Card>
|
||||
<Card><CardContent className="p-3"><p className="text-xs text-muted-foreground">Fără spații</p><p className="text-xl font-bold">{charsNoSpaces}</p></CardContent></Card>
|
||||
<Card><CardContent className="p-3"><p className="text-xs text-muted-foreground">Cuvinte</p><p className="text-xl font-bold">{words}</p></CardContent></Card>
|
||||
<Card><CardContent className="p-3"><p className="text-xs text-muted-foreground">Linii</p><p className="text-xl font-bold">{lines}</p></CardContent></Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PercentageCalculator() {
|
||||
const [value, setValue] = useState('');
|
||||
const [total, setTotal] = useState('');
|
||||
const [percent, setPercent] = useState('');
|
||||
|
||||
const v = parseFloat(value);
|
||||
const t = parseFloat(total);
|
||||
const p = parseFloat(percent);
|
||||
|
||||
const pctOfTotal = !isNaN(v) && !isNaN(t) && t !== 0 ? ((v / t) * 100).toFixed(2) : '—';
|
||||
const valFromPct = !isNaN(p) && !isNaN(t) ? ((p / 100) * t).toFixed(2) : '—';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div><Label>Valoare</Label><Input type="number" value={value} onChange={(e) => setValue(e.target.value)} className="mt-1" /></div>
|
||||
<div><Label>Total</Label><Input type="number" value={total} onChange={(e) => setTotal(e.target.value)} className="mt-1" /></div>
|
||||
<div><Label>Procent</Label><Input type="number" value={percent} onChange={(e) => setPercent(e.target.value)} className="mt-1" /></div>
|
||||
</div>
|
||||
<div className="space-y-2 rounded-md border bg-muted/30 p-3 text-sm">
|
||||
<p><strong>{value || '?'}</strong> din <strong>{total || '?'}</strong> = <strong>{pctOfTotal}%</strong></p>
|
||||
<p><strong>{percent || '?'}%</strong> din <strong>{total || '?'}</strong> = <strong>{valFromPct}</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AreaConverter() {
|
||||
const [mp, setMp] = useState('');
|
||||
const v = parseFloat(mp);
|
||||
|
||||
const conversions = !isNaN(v) ? [
|
||||
{ label: 'mp (m²)', value: v.toFixed(2) },
|
||||
{ label: 'ari (100 m²)', value: (v / 100).toFixed(4) },
|
||||
{ label: 'hectare (10.000 m²)', value: (v / 10000).toFixed(6) },
|
||||
{ label: 'km²', value: (v / 1000000).toFixed(8) },
|
||||
{ label: 'sq ft', value: (v * 10.7639).toFixed(2) },
|
||||
] : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div><Label>Suprafață (m²)</Label><Input type="number" value={mp} onChange={(e) => setMp(e.target.value)} className="mt-1" placeholder="Introdu suprafața..." /></div>
|
||||
{conversions.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{conversions.map(({ label, value: val }) => (
|
||||
<div key={label} className="flex items-center gap-2">
|
||||
<code className="flex-1 rounded border bg-muted/30 px-2 py-1 text-xs">{val}</code>
|
||||
<span className="w-36 text-xs text-muted-foreground">{label}</span>
|
||||
<CopyButton text={val} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MiniUtilitiesModule() {
|
||||
return (
|
||||
<Tabs defaultValue="text-case" className="space-y-4">
|
||||
<TabsList className="flex-wrap">
|
||||
<TabsTrigger value="text-case"><Type className="mr-1 h-3.5 w-3.5" /> Transformare text</TabsTrigger>
|
||||
<TabsTrigger value="char-count"><Hash className="mr-1 h-3.5 w-3.5" /> Numărare caractere</TabsTrigger>
|
||||
<TabsTrigger value="percentage"><Percent className="mr-1 h-3.5 w-3.5" /> Procente</TabsTrigger>
|
||||
<TabsTrigger value="area"><Ruler className="mr-1 h-3.5 w-3.5" /> Convertor suprafețe</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="text-case">
|
||||
<Card><CardHeader><CardTitle className="text-base">Transformare text</CardTitle></CardHeader>
|
||||
<CardContent><TextCaseConverter /></CardContent></Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="char-count">
|
||||
<Card><CardHeader><CardTitle className="text-base">Numărare caractere</CardTitle></CardHeader>
|
||||
<CardContent><CharacterCounter /></CardContent></Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="percentage">
|
||||
<Card><CardHeader><CardTitle className="text-base">Calculator procente</CardTitle></CardHeader>
|
||||
<CardContent><PercentageCalculator /></CardContent></Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="area">
|
||||
<Card><CardHeader><CardTitle className="text-base">Convertor suprafețe</CardTitle></CardHeader>
|
||||
<CardContent><AreaConverter /></CardContent></Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
17
src/modules/mini-utilities/config.ts
Normal file
17
src/modules/mini-utilities/config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ModuleConfig } from '@/core/module-registry/types';
|
||||
|
||||
export const miniUtilitiesConfig: ModuleConfig = {
|
||||
id: 'mini-utilities',
|
||||
name: 'Utilitare',
|
||||
description: 'Colecție de instrumente utilitare rapide: calculatoare, convertoare, formatare',
|
||||
icon: 'calculator',
|
||||
route: '/mini-utilities',
|
||||
category: 'tools',
|
||||
featureFlag: 'module.mini-utilities',
|
||||
visibility: 'all',
|
||||
version: '0.1.0',
|
||||
dependencies: [],
|
||||
storageNamespace: 'mini-utilities',
|
||||
navOrder: 41,
|
||||
tags: ['utilitare', 'calculatoare', 'instrumente'],
|
||||
};
|
||||
3
src/modules/mini-utilities/index.ts
Normal file
3
src/modules/mini-utilities/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { miniUtilitiesConfig } from './config';
|
||||
export { MiniUtilitiesModule } from './components/mini-utilities-module';
|
||||
export type { UtilityTool } from './types';
|
||||
6
src/modules/mini-utilities/types.ts
Normal file
6
src/modules/mini-utilities/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface UtilityTool {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
component: string;
|
||||
}
|
||||
185
src/modules/password-vault/components/password-vault-module.tsx
Normal file
185
src/modules/password-vault/components/password-vault-module.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, Search, Eye, EyeOff, Copy, ExternalLink } 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 { VaultEntry, VaultEntryCategory } from '../types';
|
||||
import { useVault } from '../hooks/use-vault';
|
||||
|
||||
const CATEGORY_LABELS: Record<VaultEntryCategory, string> = {
|
||||
web: 'Web', email: 'Email', server: 'Server', database: 'Bază de date', api: 'API', other: 'Altele',
|
||||
};
|
||||
|
||||
type ViewMode = 'list' | 'add' | 'edit';
|
||||
|
||||
export function PasswordVaultModule() {
|
||||
const { entries, allEntries, loading, filters, updateFilter, addEntry, updateEntry, removeEntry } = useVault();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [editingEntry, setEditingEntry] = useState<VaultEntry | null>(null);
|
||||
const [visiblePasswords, setVisiblePasswords] = useState<Set<string>>(new Set());
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
const togglePassword = (id: string) => {
|
||||
setVisiblePasswords((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopy = async (text: string, id: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
|
||||
const handleSubmit = async (data: Omit<VaultEntry, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||
if (viewMode === 'edit' && editingEntry) {
|
||||
await updateEntry(editingEntry.id, data);
|
||||
} else {
|
||||
await addEntry(data);
|
||||
}
|
||||
setViewMode('list');
|
||||
setEditingEntry(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-2 text-xs text-amber-700 dark:text-amber-400">
|
||||
Atenție: Parolele sunt stocate local (localStorage). Nu sunt criptate. Folosiți un manager de parole dedicat pentru date sensibile.
|
||||
</div>
|
||||
|
||||
{/* 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">{allEntries.length}</p></CardContent></Card>
|
||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Web</p><p className="text-2xl font-bold">{allEntries.filter((e) => e.category === 'web').length}</p></CardContent></Card>
|
||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Server</p><p className="text-2xl font-bold">{allEntries.filter((e) => e.category === 'server').length}</p></CardContent></Card>
|
||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">API</p><p className="text-2xl font-bold">{allEntries.filter((e) => e.category === 'api').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ă..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
|
||||
</div>
|
||||
<Select value={filters.category} onValueChange={(v) => updateFilter('category', v as VaultEntryCategory | 'all')}>
|
||||
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toate</SelectItem>
|
||||
{(Object.keys(CATEGORY_LABELS) as VaultEntryCategory[]).map((c) => (
|
||||
<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</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>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Nicio intrare găsită.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry) => (
|
||||
<Card key={entry.id} className="group">
|
||||
<CardContent className="flex items-center gap-4 p-4">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">{entry.label}</p>
|
||||
<Badge variant="outline" className="text-[10px]">{CATEGORY_LABELS[entry.category]}</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{entry.username}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs">
|
||||
{visiblePasswords.has(entry.id) ? entry.encryptedPassword : '••••••••••'}
|
||||
</code>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => togglePassword(entry.id)}>
|
||||
{visiblePasswords.has(entry.id) ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => handleCopy(entry.encryptedPassword, entry.id)}>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
{copiedId === entry.id && <span className="text-[10px] text-green-500">Copiat!</span>}
|
||||
</div>
|
||||
{entry.url && (
|
||||
<p className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<ExternalLink className="h-3 w-3" /> {entry.url}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingEntry(entry); setViewMode('edit'); }}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeEntry(entry.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(viewMode === 'add' || viewMode === 'edit') && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare' : 'Intrare nouă'}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<VaultForm initial={editingEntry ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingEntry(null); }} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VaultForm({ initial, onSubmit, onCancel }: {
|
||||
initial?: VaultEntry;
|
||||
onSubmit: (data: Omit<VaultEntry, 'id' | 'createdAt' | 'updatedAt'>) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [label, setLabel] = useState(initial?.label ?? '');
|
||||
const [username, setUsername] = useState(initial?.username ?? '');
|
||||
const [password, setPassword] = useState(initial?.encryptedPassword ?? '');
|
||||
const [url, setUrl] = useState(initial?.url ?? '');
|
||||
const [category, setCategory] = useState<VaultEntryCategory>(initial?.category ?? 'web');
|
||||
const [notes, setNotes] = useState(initial?.notes ?? '');
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ label, username, encryptedPassword: password, url, category, notes, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'admin' }); }} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div><Label>Nume/Etichetă</Label><Input value={label} onChange={(e) => setLabel(e.target.value)} className="mt-1" required /></div>
|
||||
<div><Label>Categorie</Label>
|
||||
<Select value={category} onValueChange={(v) => setCategory(v as VaultEntryCategory)}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{(Object.keys(CATEGORY_LABELS) as VaultEntryCategory[]).map((c) => (<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>))}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div><Label>Utilizator</Label><Input value={username} onChange={(e) => setUsername(e.target.value)} className="mt-1" /></div>
|
||||
<div><Label>Parolă</Label><Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="mt-1" /></div>
|
||||
</div>
|
||||
<div><Label>URL</Label><Input value={url} onChange={(e) => setUrl(e.target.value)} className="mt-1" placeholder="https://..." /></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>
|
||||
);
|
||||
}
|
||||
17
src/modules/password-vault/config.ts
Normal file
17
src/modules/password-vault/config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ModuleConfig } from '@/core/module-registry/types';
|
||||
|
||||
export const passwordVaultConfig: ModuleConfig = {
|
||||
id: 'password-vault',
|
||||
name: 'Seif Parole',
|
||||
description: 'Manager securizat de parole și credențiale cu criptare locală',
|
||||
icon: 'lock',
|
||||
route: '/password-vault',
|
||||
category: 'operations',
|
||||
featureFlag: 'module.password-vault',
|
||||
visibility: 'admin',
|
||||
version: '0.1.0',
|
||||
dependencies: [],
|
||||
storageNamespace: 'password-vault',
|
||||
navOrder: 11,
|
||||
tags: ['parole', 'securitate', 'credențiale'],
|
||||
};
|
||||
74
src/modules/password-vault/hooks/use-vault.ts
Normal file
74
src/modules/password-vault/hooks/use-vault.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useStorage } from '@/core/storage';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { VaultEntry, VaultEntryCategory } from '../types';
|
||||
|
||||
const PREFIX = 'vault:';
|
||||
|
||||
export interface VaultFilters {
|
||||
search: string;
|
||||
category: VaultEntryCategory | 'all';
|
||||
}
|
||||
|
||||
export function useVault() {
|
||||
const storage = useStorage('password-vault');
|
||||
const [entries, setEntries] = useState<VaultEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState<VaultFilters>({ search: '', category: 'all' });
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const keys = await storage.list();
|
||||
const results: VaultEntry[] = [];
|
||||
for (const key of keys) {
|
||||
if (key.startsWith(PREFIX)) {
|
||||
const item = await storage.get<VaultEntry>(key);
|
||||
if (item) results.push(item);
|
||||
}
|
||||
}
|
||||
results.sort((a, b) => a.label.localeCompare(b.label));
|
||||
setEntries(results);
|
||||
setLoading(false);
|
||||
}, [storage]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
const addEntry = useCallback(async (data: Omit<VaultEntry, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||
const now = new Date().toISOString();
|
||||
const entry: VaultEntry = { ...data, id: uuid(), createdAt: now, updatedAt: now };
|
||||
await storage.set(`${PREFIX}${entry.id}`, entry);
|
||||
await refresh();
|
||||
return entry;
|
||||
}, [storage, refresh]);
|
||||
|
||||
const updateEntry = useCallback(async (id: string, updates: Partial<VaultEntry>) => {
|
||||
const existing = entries.find((e) => e.id === id);
|
||||
if (!existing) return;
|
||||
const updated = { ...existing, ...updates, id: existing.id, createdAt: existing.createdAt, updatedAt: new Date().toISOString() };
|
||||
await storage.set(`${PREFIX}${id}`, updated);
|
||||
await refresh();
|
||||
}, [storage, refresh, entries]);
|
||||
|
||||
const removeEntry = useCallback(async (id: string) => {
|
||||
await storage.delete(`${PREFIX}${id}`);
|
||||
await refresh();
|
||||
}, [storage, refresh]);
|
||||
|
||||
const updateFilter = useCallback(<K extends keyof VaultFilters>(key: K, value: VaultFilters[K]) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
const filteredEntries = entries.filter((e) => {
|
||||
if (filters.category !== 'all' && e.category !== filters.category) return false;
|
||||
if (filters.search) {
|
||||
const q = filters.search.toLowerCase();
|
||||
return e.label.toLowerCase().includes(q) || e.username.toLowerCase().includes(q) || e.url.toLowerCase().includes(q);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return { entries: filteredEntries, allEntries: entries, loading, filters, updateFilter, addEntry, updateEntry, removeEntry, refresh };
|
||||
}
|
||||
3
src/modules/password-vault/index.ts
Normal file
3
src/modules/password-vault/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { passwordVaultConfig } from './config';
|
||||
export { PasswordVaultModule } from './components/password-vault-module';
|
||||
export type { VaultEntry, VaultEntryCategory } from './types';
|
||||
23
src/modules/password-vault/types.ts
Normal file
23
src/modules/password-vault/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Visibility } from '@/core/module-registry/types';
|
||||
|
||||
export type VaultEntryCategory =
|
||||
| 'web'
|
||||
| 'email'
|
||||
| 'server'
|
||||
| 'database'
|
||||
| 'api'
|
||||
| 'other';
|
||||
|
||||
export interface VaultEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
username: string;
|
||||
encryptedPassword: string;
|
||||
url: string;
|
||||
category: VaultEntryCategory;
|
||||
notes: string;
|
||||
tags: string[];
|
||||
visibility: Visibility;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ArrowLeft, Copy, Check, Save, Trash2, History, Sparkles } 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, CardDescription } from '@/shared/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
||||
import { Separator } from '@/shared/components/ui/separator';
|
||||
import type { PromptTemplate, PromptVariable } from '../types';
|
||||
import { usePromptGenerator } from '../hooks/use-prompt-generator';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
architecture: 'Arhitectură',
|
||||
legal: 'Legal',
|
||||
technical: 'Tehnic',
|
||||
administrative: 'Administrativ',
|
||||
gis: 'GIS',
|
||||
bim: 'BIM',
|
||||
rendering: 'Vizualizare',
|
||||
procurement: 'Achiziții',
|
||||
general: 'General',
|
||||
};
|
||||
|
||||
const TARGET_LABELS: Record<string, string> = {
|
||||
text: 'Text', image: 'Imagine', code: 'Cod', review: 'Review', rewrite: 'Rescriere',
|
||||
};
|
||||
|
||||
type ViewMode = 'templates' | 'compose' | 'history';
|
||||
|
||||
export function PromptGeneratorModule() {
|
||||
const {
|
||||
allTemplates, selectedTemplate, values, composedPrompt,
|
||||
history, selectTemplate, updateValue, saveToHistory,
|
||||
deleteHistoryEntry, clearSelection,
|
||||
} = usePromptGenerator();
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('templates');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [filterCategory, setFilterCategory] = useState<string>('all');
|
||||
|
||||
const handleSelectTemplate = (template: PromptTemplate) => {
|
||||
selectTemplate(template);
|
||||
setViewMode('compose');
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(composedPrompt);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
await saveToHistory();
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
clearSelection();
|
||||
setViewMode('templates');
|
||||
};
|
||||
|
||||
const filteredTemplates = filterCategory === 'all'
|
||||
? allTemplates
|
||||
: allTemplates.filter((t) => t.category === filterCategory);
|
||||
|
||||
const usedCategories = [...new Set(allTemplates.map((t) => t.category))];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={viewMode === 'templates' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => { clearSelection(); setViewMode('templates'); }}
|
||||
>
|
||||
<Sparkles className="mr-1 h-3.5 w-3.5" /> Șabloane
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'history' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('history')}
|
||||
>
|
||||
<History className="mr-1 h-3.5 w-3.5" /> Istoric ({history.length})
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Template browser */}
|
||||
{viewMode === 'templates' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label>Categorie:</Label>
|
||||
<Select value={filterCategory} onValueChange={setFilterCategory}>
|
||||
<SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toate</SelectItem>
|
||||
{usedCategories.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>{CATEGORY_LABELS[cat] ?? cat}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{filteredTemplates.map((template) => (
|
||||
<Card
|
||||
key={template.id}
|
||||
className="cursor-pointer transition-colors hover:border-primary/50 hover:bg-accent/30"
|
||||
onClick={() => handleSelectTemplate(template)}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-sm">{template.name}</CardTitle>
|
||||
<Badge variant="outline" className="text-[10px]">{TARGET_LABELS[template.targetAiType]}</Badge>
|
||||
</div>
|
||||
<CardDescription className="text-xs">{template.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="secondary" className="text-[10px]">{CATEGORY_LABELS[template.category] ?? template.category}</Badge>
|
||||
<Badge variant="secondary" className="text-[10px]">{template.variables.length} variabile</Badge>
|
||||
<Badge variant="secondary" className="text-[10px]">{template.blocks.length} blocuri</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Composition view */}
|
||||
{viewMode === 'compose' && selectedTemplate && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={handleBack}>
|
||||
<ArrowLeft className="mr-1 h-3.5 w-3.5" /> Înapoi
|
||||
</Button>
|
||||
<div>
|
||||
<h3 className="font-semibold">{selectedTemplate.name}</h3>
|
||||
<p className="text-xs text-muted-foreground">{selectedTemplate.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Variable form */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Variabile</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{selectedTemplate.variables.map((variable) => (
|
||||
<VariableField
|
||||
key={variable.id}
|
||||
variable={variable}
|
||||
value={values[variable.id]}
|
||||
onChange={(val) => updateValue(variable.id, val)}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Output */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">Prompt compus</h3>
|
||||
<div className="flex gap-1.5">
|
||||
<Button variant="outline" size="sm" onClick={handleSave} disabled={!composedPrompt}>
|
||||
<Save className="mr-1 h-3.5 w-3.5" />
|
||||
{saved ? 'Salvat!' : 'Salvează'}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleCopy} disabled={!composedPrompt}>
|
||||
{copied ? <Check className="mr-1 h-3.5 w-3.5" /> : <Copy className="mr-1 h-3.5 w-3.5" />}
|
||||
{copied ? 'Copiat!' : 'Copiază'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-[300px] whitespace-pre-wrap rounded-lg border bg-muted/30 p-4 text-sm">
|
||||
{composedPrompt || <span className="text-muted-foreground italic">Completează variabilele pentru a genera promptul...</span>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Badge variant="outline" className="text-[10px]">Output: {selectedTemplate.outputMode}</Badge>
|
||||
<Badge variant="outline" className="text-[10px]">Blocuri: {selectedTemplate.blocks.length}</Badge>
|
||||
<Badge variant="outline" className="text-[10px]">Caractere: {composedPrompt.length}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History view */}
|
||||
{viewMode === 'history' && (
|
||||
<div className="space-y-3">
|
||||
{history.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Niciun prompt salvat în istoric.</p>
|
||||
) : (
|
||||
history.map((entry) => (
|
||||
<Card key={entry.id} className="group">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">{entry.templateName}</p>
|
||||
<Badge variant="outline" className="text-[10px]">{entry.outputMode}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{new Date(entry.createdAt).toLocaleString('ro-RO')} — {entry.composedPrompt.length} caractere
|
||||
</p>
|
||||
<pre className="mt-2 max-h-24 overflow-hidden truncate text-xs text-muted-foreground">
|
||||
{entry.composedPrompt.slice(0, 200)}...
|
||||
</pre>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<CopyHistoryButton text={entry.composedPrompt} />
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => deleteHistoryEntry(entry.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VariableField({ variable, value, onChange }: {
|
||||
variable: PromptVariable;
|
||||
value: unknown;
|
||||
onChange: (val: unknown) => void;
|
||||
}) {
|
||||
const strVal = value !== undefined && value !== null ? String(value) : '';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label className={cn(variable.required && 'after:content-["*"] after:ml-0.5 after:text-destructive')}>
|
||||
{variable.label}
|
||||
</Label>
|
||||
{variable.helperText && (
|
||||
<p className="text-[11px] text-muted-foreground">{variable.helperText}</p>
|
||||
)}
|
||||
|
||||
{(variable.type === 'text' || variable.type === 'number') && (
|
||||
<Input
|
||||
type={variable.type === 'number' ? 'number' : 'text'}
|
||||
value={strVal}
|
||||
onChange={(e) => onChange(variable.type === 'number' ? Number(e.target.value) : e.target.value)}
|
||||
placeholder={variable.placeholder}
|
||||
className="mt-1"
|
||||
/>
|
||||
)}
|
||||
|
||||
{(variable.type === 'select' || variable.type === 'tone-selector' || variable.type === 'company-selector') && variable.options && (
|
||||
<Select value={strVal} onValueChange={onChange}>
|
||||
<SelectTrigger className="mt-1"><SelectValue placeholder="Selectează..." /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{variable.options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{variable.type === 'boolean' && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!value}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="h-4 w-4 rounded accent-primary"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{variable.placeholder ?? 'Da/Nu'}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{variable.type === 'multi-select' && variable.options && (
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{variable.options.map((opt) => {
|
||||
const selected = Array.isArray(value) && (value as string[]).includes(opt.value);
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const arr = Array.isArray(value) ? [...(value as string[])] : [];
|
||||
if (selected) onChange(arr.filter((v) => v !== opt.value));
|
||||
else onChange([...arr, opt.value]);
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-full border px-2.5 py-0.5 text-xs transition-colors',
|
||||
selected ? 'border-primary bg-primary text-primary-foreground' : 'hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyHistoryButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
return (
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopy}>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
17
src/modules/prompt-generator/config.ts
Normal file
17
src/modules/prompt-generator/config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ModuleConfig } from '@/core/module-registry/types';
|
||||
|
||||
export const promptGeneratorConfig: ModuleConfig = {
|
||||
id: 'prompt-generator',
|
||||
name: 'Generator Prompturi',
|
||||
description: 'Generator structurat de prompturi pe bază de șabloane parametrizate, organizate pe domenii profesionale',
|
||||
icon: 'sparkles',
|
||||
route: '/prompt-generator',
|
||||
category: 'ai',
|
||||
featureFlag: 'module.prompt-generator',
|
||||
visibility: 'all',
|
||||
version: '0.1.0',
|
||||
dependencies: [],
|
||||
storageNamespace: 'prompt-generator',
|
||||
navOrder: 50,
|
||||
tags: ['prompt', 'ai', 'generator', 'șabloane'],
|
||||
};
|
||||
110
src/modules/prompt-generator/hooks/use-prompt-generator.ts
Normal file
110
src/modules/prompt-generator/hooks/use-prompt-generator.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useStorage } from '@/core/storage';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { PromptTemplate, PromptHistoryEntry, OutputMode } from '../types';
|
||||
import { BUILTIN_TEMPLATES } from '../services/builtin-templates';
|
||||
import { composePrompt } from '../services/prompt-composer';
|
||||
|
||||
const HISTORY_PREFIX = 'history:';
|
||||
const TEMPLATE_PREFIX = 'template:';
|
||||
|
||||
export function usePromptGenerator() {
|
||||
const storage = useStorage('prompt-generator');
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<PromptTemplate | null>(null);
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [customTemplates, setCustomTemplates] = useState<PromptTemplate[]>([]);
|
||||
const [history, setHistory] = useState<PromptHistoryEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const allTemplates = useMemo(() => [...BUILTIN_TEMPLATES, ...customTemplates], [customTemplates]);
|
||||
|
||||
// Load custom templates and history
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
const keys = await storage.list();
|
||||
const templates: PromptTemplate[] = [];
|
||||
const entries: PromptHistoryEntry[] = [];
|
||||
for (const key of keys) {
|
||||
if (key.startsWith(TEMPLATE_PREFIX)) {
|
||||
const t = await storage.get<PromptTemplate>(key);
|
||||
if (t) templates.push(t);
|
||||
} else if (key.startsWith(HISTORY_PREFIX)) {
|
||||
const h = await storage.get<PromptHistoryEntry>(key);
|
||||
if (h) entries.push(h);
|
||||
}
|
||||
}
|
||||
entries.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
setCustomTemplates(templates);
|
||||
setHistory(entries);
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, [storage]);
|
||||
|
||||
const selectTemplate = useCallback((template: PromptTemplate) => {
|
||||
setSelectedTemplate(template);
|
||||
// Initialize values with defaults
|
||||
const defaults: Record<string, unknown> = {};
|
||||
for (const v of template.variables) {
|
||||
if (v.defaultValue !== undefined) defaults[v.id] = v.defaultValue;
|
||||
}
|
||||
setValues(defaults);
|
||||
}, []);
|
||||
|
||||
const updateValue = useCallback((variableId: string, value: unknown) => {
|
||||
setValues((prev) => ({ ...prev, [variableId]: value }));
|
||||
}, []);
|
||||
|
||||
const composedPrompt = useMemo(() => {
|
||||
if (!selectedTemplate) return '';
|
||||
return composePrompt(selectedTemplate, values);
|
||||
}, [selectedTemplate, values]);
|
||||
|
||||
const saveToHistory = useCallback(async () => {
|
||||
if (!selectedTemplate || !composedPrompt) return;
|
||||
const entry: PromptHistoryEntry = {
|
||||
id: uuid(),
|
||||
templateId: selectedTemplate.id,
|
||||
templateName: selectedTemplate.name,
|
||||
templateVersion: selectedTemplate.version,
|
||||
values: { ...values },
|
||||
composedPrompt,
|
||||
outputMode: selectedTemplate.outputMode,
|
||||
providerProfile: selectedTemplate.providerProfile ?? null,
|
||||
safetyBlocks: selectedTemplate.safetyBlocks?.filter((s) => s.enabled).map((s) => s.id) ?? [],
|
||||
tags: selectedTemplate.tags,
|
||||
isFavorite: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await storage.set(`${HISTORY_PREFIX}${entry.id}`, entry);
|
||||
setHistory((prev) => [entry, ...prev]);
|
||||
return entry;
|
||||
}, [storage, selectedTemplate, values, composedPrompt]);
|
||||
|
||||
const deleteHistoryEntry = useCallback(async (id: string) => {
|
||||
await storage.delete(`${HISTORY_PREFIX}${id}`);
|
||||
setHistory((prev) => prev.filter((h) => h.id !== id));
|
||||
}, [storage]);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedTemplate(null);
|
||||
setValues({});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
allTemplates,
|
||||
selectedTemplate,
|
||||
values,
|
||||
composedPrompt,
|
||||
history,
|
||||
loading,
|
||||
selectTemplate,
|
||||
updateValue,
|
||||
saveToHistory,
|
||||
deleteHistoryEntry,
|
||||
clearSelection,
|
||||
};
|
||||
}
|
||||
16
src/modules/prompt-generator/index.ts
Normal file
16
src/modules/prompt-generator/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export { promptGeneratorConfig } from './config';
|
||||
export { PromptGeneratorModule } from './components/prompt-generator-module';
|
||||
export type {
|
||||
BlockType,
|
||||
OutputMode,
|
||||
PromptBlock,
|
||||
PromptCategory,
|
||||
PromptDomain,
|
||||
PromptHistoryEntry,
|
||||
PromptTemplate,
|
||||
PromptVariable,
|
||||
ProviderProfile,
|
||||
SafetyBlock,
|
||||
SelectOption,
|
||||
VariableType,
|
||||
} from './types';
|
||||
146
src/modules/prompt-generator/services/builtin-templates.ts
Normal file
146
src/modules/prompt-generator/services/builtin-templates.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { PromptTemplate } from '../types';
|
||||
|
||||
export const BUILTIN_TEMPLATES: PromptTemplate[] = [
|
||||
{
|
||||
id: 'arch-description',
|
||||
name: 'Descriere Proiect Arhitectură',
|
||||
category: 'architecture',
|
||||
domain: 'architecture-visualization',
|
||||
description: 'Generează o descriere narativă a unui proiect de arhitectură pentru documentație sau prezentare.',
|
||||
targetAiType: 'text',
|
||||
blocks: [
|
||||
{ id: 'b1', type: 'role', label: 'Rol', content: 'Ești un arhitect experimentat, specialist în prezentări de proiecte și memorii tehnice.', order: 1, required: true },
|
||||
{ id: 'b2', type: 'context', label: 'Context', content: 'Proiectul se numește "{{projectName}}" și este situat în {{location}}. Tipul proiectului: {{projectType}}. Suprafața terenului: {{landArea}} mp. Beneficiar: {{clientName}}.', order: 2, required: true },
|
||||
{ id: 'b3', type: 'task', label: 'Sarcină', content: 'Scrie o descriere narativă a proiectului pentru {{targetDocument}}. Tonul trebuie să fie {{tone}}.', order: 3, required: true },
|
||||
{ id: 'b4', type: 'constraints', label: 'Constrângeri', content: 'Lungimea textului: {{textLength}}. Limba: română. Include referințe la specificul local și la contextul urbanistic.', order: 4, required: true },
|
||||
],
|
||||
variables: [
|
||||
{ id: 'projectName', label: 'Nume proiect', type: 'text', required: true, placeholder: 'ex: Locuință unifamilială P+1' },
|
||||
{ id: 'location', label: 'Locație', type: 'text', required: true, placeholder: 'ex: Cluj-Napoca, str. Unirii nr. 3' },
|
||||
{ id: 'projectType', label: 'Tip proiect', type: 'select', required: true, options: [
|
||||
{ value: 'residential', label: 'Rezidențial' }, { value: 'commercial', label: 'Comercial' },
|
||||
{ value: 'mixed', label: 'Mixt' }, { value: 'industrial', label: 'Industrial' },
|
||||
{ value: 'public', label: 'Public' },
|
||||
]},
|
||||
{ id: 'landArea', label: 'Suprafață teren (mp)', type: 'number', required: false, placeholder: '500' },
|
||||
{ id: 'clientName', label: 'Beneficiar', type: 'text', required: false, placeholder: 'Nume client' },
|
||||
{ id: 'targetDocument', label: 'Document țintă', type: 'select', required: true, options: [
|
||||
{ value: 'memoriu tehnic', label: 'Memoriu tehnic' }, { value: 'prezentare client', label: 'Prezentare client' },
|
||||
{ value: 'documentație urbanism', label: 'Documentație urbanism' },
|
||||
]},
|
||||
{ id: 'tone', label: 'Ton', type: 'select', required: true, options: [
|
||||
{ value: 'tehnic și formal', label: 'Tehnic/Formal' }, { value: 'narativ și inspirațional', label: 'Narativ' },
|
||||
{ value: 'concis și factual', label: 'Concis' },
|
||||
]},
|
||||
{ id: 'textLength', label: 'Lungime', type: 'select', required: true, options: [
|
||||
{ value: '200-300 cuvinte', label: 'Scurt (200-300 cuvinte)' }, { value: '500-700 cuvinte', label: 'Mediu (500-700)' },
|
||||
{ value: '1000+ cuvinte', label: 'Lung (1000+)' },
|
||||
]},
|
||||
],
|
||||
outputMode: 'expanded-expert',
|
||||
tags: ['arhitectură', 'memoriu', 'descriere'],
|
||||
version: '1.0.0',
|
||||
author: 'ArchiTools',
|
||||
visibility: 'all',
|
||||
},
|
||||
{
|
||||
id: 'legal-review',
|
||||
name: 'Verificare Conformitate Legislativă',
|
||||
category: 'legal',
|
||||
domain: 'legal-review',
|
||||
description: 'Generează un prompt pentru verificarea conformității unui document cu legislația aplicabilă.',
|
||||
targetAiType: 'review',
|
||||
blocks: [
|
||||
{ id: 'b1', type: 'role', label: 'Rol', content: 'Ești un consultant juridic specializat în legislația construcțiilor și urbanismului din România.', order: 1, required: true },
|
||||
{ id: 'b2', type: 'context', label: 'Context', content: 'Documentul de verificat: {{documentType}}. Faza de proiectare: {{projectPhase}}.', order: 2, required: true },
|
||||
{ id: 'b3', type: 'task', label: 'Sarcină', content: 'Verifică conformitatea cu: {{regulations}}. Identifică potențiale neconformități și recomandă acțiuni corective.', order: 3, required: true },
|
||||
],
|
||||
variables: [
|
||||
{ id: 'documentType', label: 'Tip document', type: 'select', required: true, options: [
|
||||
{ value: 'CU', label: 'Certificat Urbanism' }, { value: 'DTAC', label: 'DTAC' },
|
||||
{ value: 'PT', label: 'Proiect Tehnic' }, { value: 'PUZ', label: 'PUZ' }, { value: 'PUD', label: 'PUD' },
|
||||
]},
|
||||
{ id: 'projectPhase', label: 'Fază proiect', type: 'select', required: true, options: [
|
||||
{ value: 'studiu fezabilitate', label: 'Studiu fezabilitate' }, { value: 'proiectare', label: 'Proiectare' },
|
||||
{ value: 'autorizare', label: 'Autorizare' }, { value: 'execuție', label: 'Execuție' },
|
||||
]},
|
||||
{ id: 'regulations', label: 'Legislație de referință', type: 'text', required: true, placeholder: 'ex: Legea 50/1991, Normativ P118' },
|
||||
],
|
||||
outputMode: 'checklist',
|
||||
tags: ['legal', 'conformitate', 'verificare'],
|
||||
version: '1.0.0',
|
||||
author: 'ArchiTools',
|
||||
visibility: 'all',
|
||||
},
|
||||
{
|
||||
id: 'tech-spec',
|
||||
name: 'Specificație Tehnică',
|
||||
category: 'technical',
|
||||
domain: 'technical-documentation',
|
||||
description: 'Generează o specificație tehnică pentru un element de proiect.',
|
||||
targetAiType: 'text',
|
||||
blocks: [
|
||||
{ id: 'b1', type: 'role', label: 'Rol', content: 'Ești un inginer de specialitate cu experiență în redactarea specificațiilor tehnice pentru proiecte de construcții.', order: 1, required: true },
|
||||
{ id: 'b2', type: 'task', label: 'Sarcină', content: 'Redactează o specificație tehnică pentru: {{element}}. Nivelul de detaliu: {{detailLevel}}.', order: 2, required: true },
|
||||
{ id: 'b3', type: 'format', label: 'Format', content: 'Structurează specificația cu: descriere generală, materiale, dimensiuni, cerințe de performanță, standarde aplicabile.', order: 3, required: true },
|
||||
],
|
||||
variables: [
|
||||
{ id: 'element', label: 'Element de proiect', type: 'text', required: true, placeholder: 'ex: Structură metalică hală industrială' },
|
||||
{ id: 'detailLevel', label: 'Nivel detaliu', type: 'select', required: true, options: [
|
||||
{ value: 'orientativ', label: 'Orientativ' }, { value: 'detaliat', label: 'Detaliat' }, { value: 'execuție', label: 'Nivel execuție' },
|
||||
]},
|
||||
],
|
||||
outputMode: 'step-by-step',
|
||||
tags: ['tehnic', 'specificație', 'documentație'],
|
||||
version: '1.0.0',
|
||||
author: 'ArchiTools',
|
||||
visibility: 'all',
|
||||
},
|
||||
{
|
||||
id: 'image-prompt',
|
||||
name: 'Prompt Vizualizare Arhitecturală',
|
||||
category: 'rendering',
|
||||
domain: 'architecture-visualization',
|
||||
description: 'Generează un prompt optimizat pentru AI de generare imagini (Midjourney, DALL-E, Stable Diffusion).',
|
||||
targetAiType: 'image',
|
||||
blocks: [
|
||||
{ id: 'b1', type: 'task', label: 'Descriere', content: '{{buildingType}}, {{style}}, situated in {{setting}}.', order: 1, required: true },
|
||||
{ id: 'b2', type: 'context', label: 'Atmosferă', content: '{{atmosphere}}. {{timeOfDay}} lighting. {{season}}.', order: 2, required: true },
|
||||
{ id: 'b3', type: 'format', label: 'Parametri tehnici', content: '{{cameraAngle}}, {{renderStyle}}, high quality, detailed, 8k resolution.', order: 3, required: true },
|
||||
],
|
||||
variables: [
|
||||
{ id: 'buildingType', label: 'Tip clădire', type: 'text', required: true, placeholder: 'ex: Modern minimalist villa with flat roof' },
|
||||
{ id: 'style', label: 'Stil arhitectural', type: 'select', required: true, options: [
|
||||
{ value: 'modern minimalist', label: 'Modern minimalist' }, { value: 'contemporary', label: 'Contemporan' },
|
||||
{ value: 'traditional Romanian', label: 'Tradițional românesc' }, { value: 'brutalist', label: 'Brutalist' },
|
||||
{ value: 'art deco', label: 'Art Deco' },
|
||||
]},
|
||||
{ id: 'setting', label: 'Amplasament', type: 'text', required: true, placeholder: 'ex: hillside with forest background' },
|
||||
{ id: 'atmosphere', label: 'Atmosferă', type: 'select', required: true, options: [
|
||||
{ value: 'Warm and inviting', label: 'Cald/Primitor' }, { value: 'Dramatic and bold', label: 'Dramatic' },
|
||||
{ value: 'Serene and peaceful', label: 'Calm/Liniștit' }, { value: 'Urban and dynamic', label: 'Urban/Dinamic' },
|
||||
]},
|
||||
{ id: 'timeOfDay', label: 'Moment al zilei', type: 'select', required: true, options: [
|
||||
{ value: 'golden hour', label: 'Golden hour' }, { value: 'midday', label: 'Amiază' },
|
||||
{ value: 'blue hour / dusk', label: 'Blue hour' }, { value: 'night with interior lights', label: 'Noapte' },
|
||||
]},
|
||||
{ id: 'season', label: 'Anotimp', type: 'select', required: false, options: [
|
||||
{ value: 'Spring with green vegetation', label: 'Primăvară' }, { value: 'Summer', label: 'Vară' },
|
||||
{ value: 'Autumn with warm colors', label: 'Toamnă' }, { value: 'Winter with snow', label: 'Iarnă' },
|
||||
]},
|
||||
{ id: 'cameraAngle', label: 'Unghi cameră', type: 'select', required: true, options: [
|
||||
{ value: 'eye-level perspective', label: 'Nivel ochi' }, { value: 'aerial drone view', label: 'Vedere aeriană' },
|
||||
{ value: 'low angle dramatic', label: 'Unghi jos' }, { value: 'birds-eye plan view', label: 'Vedere de sus' },
|
||||
]},
|
||||
{ id: 'renderStyle', label: 'Stil render', type: 'select', required: true, options: [
|
||||
{ value: 'photorealistic', label: 'Fotorealistic' }, { value: 'architectural illustration', label: 'Ilustrație' },
|
||||
{ value: 'watercolor sketch', label: 'Acuarelă' }, { value: 'technical section render', label: 'Secțiune' },
|
||||
]},
|
||||
],
|
||||
outputMode: 'short',
|
||||
tags: ['render', 'imagine', 'vizualizare', 'midjourney'],
|
||||
version: '1.0.0',
|
||||
author: 'ArchiTools',
|
||||
visibility: 'all',
|
||||
},
|
||||
];
|
||||
75
src/modules/prompt-generator/services/prompt-composer.ts
Normal file
75
src/modules/prompt-generator/services/prompt-composer.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { PromptTemplate, PromptBlock, OutputMode } from '../types';
|
||||
|
||||
const OUTPUT_MODE_INSTRUCTIONS: Record<OutputMode, string> = {
|
||||
short: 'Răspunde concis, maximum 2-3 paragrafe.',
|
||||
'expanded-expert': 'Răspunde detaliat, ca expert în domeniu. Folosește terminologie profesională.',
|
||||
'step-by-step': 'Răspunde pas cu pas, cu numerotare clară.',
|
||||
'chain-of-thought': 'Gândește cu voce tare. Arată raționamentul pas cu pas înainte de a da răspunsul final.',
|
||||
'structured-json': 'Răspunde în format JSON structurat.',
|
||||
checklist: 'Răspunde sub formă de checklist cu casete de bifat (- [ ] item).',
|
||||
};
|
||||
|
||||
function interpolateVariables(content: string, values: Record<string, unknown>): string {
|
||||
return content.replace(/\{\{(\w+)\}\}/g, (match, key: string) => {
|
||||
const val = values[key];
|
||||
if (val === undefined || val === null || val === '') return match;
|
||||
if (Array.isArray(val)) return val.join(', ');
|
||||
return String(val);
|
||||
});
|
||||
}
|
||||
|
||||
function shouldIncludeBlock(block: PromptBlock, values: Record<string, unknown>): boolean {
|
||||
if (!block.conditional) return true;
|
||||
const { variableId, operator, value } = block.conditional;
|
||||
const actual = values[variableId];
|
||||
|
||||
switch (operator) {
|
||||
case 'truthy': return !!actual;
|
||||
case 'falsy': return !actual;
|
||||
case 'equals': return String(actual) === value;
|
||||
case 'notEquals': return String(actual) !== value;
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function composePrompt(
|
||||
template: PromptTemplate,
|
||||
values: Record<string, unknown>,
|
||||
): string {
|
||||
const blocks = [...template.blocks]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.filter((block) => shouldIncludeBlock(block, values));
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
const content = interpolateVariables(block.content, values).trim();
|
||||
if (!content) continue;
|
||||
parts.push(content);
|
||||
}
|
||||
|
||||
// Add output mode instruction
|
||||
const modeInstruction = OUTPUT_MODE_INSTRUCTIONS[template.outputMode];
|
||||
if (modeInstruction) {
|
||||
parts.push(modeInstruction);
|
||||
}
|
||||
|
||||
// Add safety blocks if enabled
|
||||
if (template.safetyBlocks) {
|
||||
const enabledSafety = template.safetyBlocks.filter((s) => s.enabled);
|
||||
if (enabledSafety.length > 0) {
|
||||
parts.push(enabledSafety.map((s) => s.content).join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
export function getUnfilledVariables(
|
||||
template: PromptTemplate,
|
||||
values: Record<string, unknown>,
|
||||
): string[] {
|
||||
return template.variables
|
||||
.filter((v) => v.required && (values[v.id] === undefined || values[v.id] === '' || values[v.id] === null))
|
||||
.map((v) => v.label);
|
||||
}
|
||||
165
src/modules/prompt-generator/types.ts
Normal file
165
src/modules/prompt-generator/types.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { Visibility } from '@/core/module-registry/types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Block system
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type BlockType =
|
||||
| 'role'
|
||||
| 'context'
|
||||
| 'task'
|
||||
| 'constraints'
|
||||
| 'format'
|
||||
| 'checklist'
|
||||
| 'validation'
|
||||
| 'output-schema'
|
||||
| 'custom';
|
||||
|
||||
export interface PromptBlock {
|
||||
id: string;
|
||||
type: BlockType;
|
||||
label: string;
|
||||
content: string;
|
||||
order: number;
|
||||
required: boolean;
|
||||
conditional?: {
|
||||
variableId: string;
|
||||
operator: 'equals' | 'notEquals' | 'truthy' | 'falsy';
|
||||
value?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variable system
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type VariableType =
|
||||
| 'text'
|
||||
| 'number'
|
||||
| 'select'
|
||||
| 'multi-select'
|
||||
| 'boolean'
|
||||
| 'tag-selector'
|
||||
| 'project-selector'
|
||||
| 'company-selector'
|
||||
| 'tone-selector'
|
||||
| 'regulation-set-selector';
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface PromptVariable {
|
||||
id: string;
|
||||
label: string;
|
||||
type: VariableType;
|
||||
required: boolean;
|
||||
defaultValue?: unknown;
|
||||
placeholder?: string;
|
||||
helperText?: string;
|
||||
options?: SelectOption[];
|
||||
validation?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
pattern?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output & provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type OutputMode =
|
||||
| 'short'
|
||||
| 'expanded-expert'
|
||||
| 'step-by-step'
|
||||
| 'chain-of-thought'
|
||||
| 'structured-json'
|
||||
| 'checklist';
|
||||
|
||||
export type PromptCategory =
|
||||
| 'architecture'
|
||||
| 'legal'
|
||||
| 'technical'
|
||||
| 'administrative'
|
||||
| 'gis'
|
||||
| 'bim'
|
||||
| 'rendering'
|
||||
| 'procurement'
|
||||
| 'general';
|
||||
|
||||
export type PromptDomain =
|
||||
| 'architecture-visualization'
|
||||
| 'technical-documentation'
|
||||
| 'legal-review'
|
||||
| 'urbanism-gis'
|
||||
| 'bim-coordination'
|
||||
| 'procurement-bidding'
|
||||
| 'administrative-writing'
|
||||
| 'general';
|
||||
|
||||
export interface ProviderProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
maxLength?: number;
|
||||
formattingPreference: 'markdown' | 'plain' | 'structured';
|
||||
instructionStyle: 'direct' | 'conversational' | 'system-prompt';
|
||||
verbosityLevel: 'concise' | 'standard' | 'detailed';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Safety blocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SafetyBlock {
|
||||
id: string;
|
||||
label: string;
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
category: 'legal' | 'tone' | 'citation' | 'disclaimer';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PromptTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
category: PromptCategory;
|
||||
domain: PromptDomain;
|
||||
description: string;
|
||||
targetAiType: 'text' | 'image' | 'code' | 'review' | 'rewrite';
|
||||
blocks: PromptBlock[];
|
||||
variables: PromptVariable[];
|
||||
outputMode: OutputMode;
|
||||
providerProfile?: string;
|
||||
safetyBlocks?: SafetyBlock[];
|
||||
tags: string[];
|
||||
version: string;
|
||||
author: string;
|
||||
visibility: Visibility;
|
||||
exampleOutputs?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// History
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PromptHistoryEntry {
|
||||
id: string;
|
||||
templateId: string;
|
||||
templateName: string;
|
||||
templateVersion: string;
|
||||
values: Record<string, unknown>;
|
||||
composedPrompt: string;
|
||||
outputMode: OutputMode;
|
||||
providerProfile: string | null;
|
||||
safetyBlocks: string[];
|
||||
tags: string[];
|
||||
isFavorite: boolean;
|
||||
createdAt: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
128
src/modules/registratura/components/registratura-module.tsx
Normal file
128
src/modules/registratura/components/registratura-module.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Button } from '@/shared/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
||||
import { Badge } from '@/shared/components/ui/badge';
|
||||
import { useRegistry } from '../hooks/use-registry';
|
||||
import { RegistryFilters } from './registry-filters';
|
||||
import { RegistryTable } from './registry-table';
|
||||
import { RegistryEntryForm } from './registry-entry-form';
|
||||
import type { RegistryEntry } from '../types';
|
||||
|
||||
type ViewMode = 'list' | 'add' | 'edit';
|
||||
|
||||
export function RegistraturaModule() {
|
||||
const {
|
||||
entries, allEntries, loading, filters, updateFilter,
|
||||
addEntry, updateEntry, removeEntry,
|
||||
} = useRegistry();
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [editingEntry, setEditingEntry] = useState<RegistryEntry | null>(null);
|
||||
|
||||
const handleAdd = async (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => {
|
||||
await addEntry(data);
|
||||
setViewMode('list');
|
||||
};
|
||||
|
||||
const handleEdit = (entry: RegistryEntry) => {
|
||||
setEditingEntry(entry);
|
||||
setViewMode('edit');
|
||||
};
|
||||
|
||||
const handleUpdate = async (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => {
|
||||
if (!editingEntry) return;
|
||||
await updateEntry(editingEntry.id, data);
|
||||
setEditingEntry(null);
|
||||
setViewMode('list');
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await removeEntry(id);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setViewMode('list');
|
||||
setEditingEntry(null);
|
||||
};
|
||||
|
||||
// Stats
|
||||
const total = allEntries.length;
|
||||
const incoming = allEntries.filter((e) => e.type === 'incoming').length;
|
||||
const outgoing = allEntries.filter((e) => e.type === 'outgoing').length;
|
||||
const inProgress = allEntries.filter((e) => e.status === 'in-progress').length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatCard label="Total" value={total} />
|
||||
<StatCard label="Intrare" value={incoming} />
|
||||
<StatCard label="Ieșire" value={outgoing} />
|
||||
<StatCard label="În lucru" value={inProgress} />
|
||||
</div>
|
||||
|
||||
{viewMode === 'list' && (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<RegistryFilters filters={filters} onUpdate={updateFilter} />
|
||||
<Button onClick={() => setViewMode('add')} className="shrink-0">
|
||||
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<RegistryTable
|
||||
entries={entries}
|
||||
loading={loading}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
{!loading && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{entries.length} din {total} înregistrări afișate
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{viewMode === 'add' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
Înregistrare nouă
|
||||
<Badge variant="outline" className="text-xs">Nr. auto</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RegistryEntryForm onSubmit={handleAdd} onCancel={handleCancel} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{viewMode === 'edit' && editingEntry && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Editare — {editingEntry.number}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RegistryEntryForm initial={editingEntry} onSubmit={handleUpdate} onCancel={handleCancel} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user