feat(registratura): 3.02 bidirectional integration, simplified status, threads
- Dynamic document types: string-based DocumentType synced with Tag Manager (new types auto-create tags under 'document-type' category) - Added default types: 'Apel telefonic', 'Videoconferinta' - Bidirectional Address Book: quick-create contacts from sender/recipient/ assignee fields via QuickContactDialog popup - Simplified status: Switch toggle replaces dropdown (default open) - Responsabil (Assignee) field with contact autocomplete (ERP-ready) - Entry threads: threadParentId links entries as replies, ThreadView shows parent/current/children tree with branching support - Info tooltips on deadline, status, and assignee fields - New Resp. column and thread icon in registry table - All changes backward-compatible with existing data
This commit is contained in:
+20
-11
@@ -252,7 +252,7 @@
|
|||||||
|
|
||||||
**Status:** ✅ Done. Sidebar redesigned: logos in centered row (flex-1, theme-aware, dual-render light+dark), "ArchiTools" text below, animated theme toggle (Sun/Moon lucide icons, gradient background, stars/clouds).
|
**Status:** ✅ Done. Sidebar redesigned: logos in centered row (flex-1, theme-aware, dual-render light+dark), "ArchiTools" text below, animated theme toggle (Sun/Moon lucide icons, gradient background, stars/clouds).
|
||||||
|
|
||||||
### 3.02 `[BUSINESS]` Registratura — Integrare și UX
|
### 3.02 ✅ `[BUSINESS]` Registratura — Integrare și UX (2026-02-27)
|
||||||
|
|
||||||
**Cerințe noi:**
|
**Cerințe noi:**
|
||||||
|
|
||||||
@@ -263,6 +263,14 @@
|
|||||||
- **Pregătire Integrare ERP (Responsabil):** Adăugarea unui câmp "Responsabil" (Assignee) pentru a aloca sarcini interne, gândit arhitectural pentru a putea fi expus/sincronizat ușor printr-un API/Webhook viitor către un sistem ERP extern.
|
- **Pregătire Integrare ERP (Responsabil):** Adăugarea unui câmp "Responsabil" (Assignee) pentru a aloca sarcini interne, gândit arhitectural pentru a putea fi expus/sincronizat ușor printr-un API/Webhook viitor către un sistem ERP extern.
|
||||||
- **Legături între intrări/ieșiri (Thread-uri & Branches):** Posibilitatea de a lega o ieșire de o intrare specifică (ex: "Răspuns la adresa nr. X"), creând un "fir" (thread) vizual al conversației instituționale. Trebuie să suporte și "branching" (ex: o intrare generează mai multe ieșiri conexe către instituții diferite). UI-ul trebuie să rămână extrem de simplu și intuitiv (ex: vizualizare tip arbore simplificat sau listă indentată în detaliile documentului).
|
- **Legături între intrări/ieșiri (Thread-uri & Branches):** Posibilitatea de a lega o ieșire de o intrare specifică (ex: "Răspuns la adresa nr. X"), creând un "fir" (thread) vizual al conversației instituționale. Trebuie să suporte și "branching" (ex: o intrare generează mai multe ieșiri conexe către instituții diferite). UI-ul trebuie să rămână extrem de simplu și intuitiv (ex: vizualizare tip arbore simplificat sau listă indentată în detaliile documentului).
|
||||||
|
|
||||||
|
**Status:** ✅ Done. All 6 sub-features implemented:
|
||||||
|
1. **Dynamic doc types** — Select + inline "Tip nou" input. New types auto-created as Tag Manager tags (category: document-type). Added "Apel telefonic" and "Videoconferință" as defaults.
|
||||||
|
2. **Bidirectional Address Book** — Autocomplete shows "Creează contact" button when no match. QuickContactDialog popup creates contact in Address Book with minimal data (Name required, Phone/Email optional).
|
||||||
|
3. **Simplified status** — Replaced Status dropdown with Switch toggle "Închis/Deschis". Default is open.
|
||||||
|
4. **Internal deadline tooltip** — Added Info tooltip explaining "Termen limită intern" vs legal deadlines. Also added tooltips on "Închis" and "Responsabil" fields.
|
||||||
|
5. **Responsabil (Assignee)** — New field with contact autocomplete + quick-create. ERP-ready with separate assigneeContactId. Shown in registry table as "Resp." column.
|
||||||
|
6. **Threads & Branching** — `threadParentId` field links entries as reply-to. Thread search with direction badges. ThreadView component shows parent, current entry, siblings (branches), and child replies as indented tree. Thread icon in table. Click to navigate between threaded entries.
|
||||||
|
|
||||||
### 3.03 `[BUSINESS]` Registratura — Termene Legale (Flux Nou)
|
### 3.03 `[BUSINESS]` Registratura — Termene Legale (Flux Nou)
|
||||||
|
|
||||||
**Cerințe noi:**
|
**Cerințe noi:**
|
||||||
@@ -519,6 +527,7 @@ AI_DEFAULT_MODEL=claude-sonnet-4-6-20261001
|
|||||||
**Status:** ✅ Done. NextAuth v4 + Authentik OIDC provider configured. Group→role mapping (authentik groups → admin/manager/user). Group→company mapping (beletage/urban-switch/studii-de-teren). Cookie-based session. `useAuth()` returns real user. Header shows user name/email + logout. Sign in with Authentik page works.
|
**Status:** ✅ Done. NextAuth v4 + Authentik OIDC provider configured. Group→role mapping (authentik groups → admin/manager/user). Group→company mapping (beletage/urban-switch/studii-de-teren). Cookie-based session. `useAuth()` returns real user. Header shows user name/email + logout. Sign in with Authentik page works.
|
||||||
|
|
||||||
Env vars (hardcoded in docker-compose.yml for Portainer CE):
|
Env vars (hardcoded in docker-compose.yml for Portainer CE):
|
||||||
|
|
||||||
- `NEXTAUTH_URL=https://tools.beletage.ro`
|
- `NEXTAUTH_URL=https://tools.beletage.ro`
|
||||||
- `NEXTAUTH_SECRET`, `AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET`, `AUTHENTIK_ISSUER`
|
- `NEXTAUTH_SECRET`, `AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET`, `AUTHENTIK_ISSUER`
|
||||||
|
|
||||||
@@ -661,16 +670,16 @@ Env vars (hardcoded in docker-compose.yml for Portainer CE):
|
|||||||
|
|
||||||
## Infrastructure Credentials Needed
|
## Infrastructure Credentials Needed
|
||||||
|
|
||||||
| Service | What | When Needed | Status |
|
| Service | What | When Needed | Status |
|
||||||
| ------------------------ | --------------------------------------- | ------------------- | ------ |
|
| ------------------------ | --------------------------------------- | --------------------- | --------------------- |
|
||||||
| **US/SDT Logos** | SVG/PNG logo files | Phase 1 (task 1.01) | ✅ Done |
|
| **US/SDT Logos** | SVG/PNG logo files | Phase 1 (task 1.01) | ✅ Done |
|
||||||
| **US/SDT Addresses** | Office addresses for email signature | Phase 1 (task 1.02) | ✅ Done (placeholder) |
|
| **US/SDT Addresses** | Office addresses for email signature | Phase 1 (task 1.02) | ✅ Done (placeholder) |
|
||||||
| **Anthropic API Key** | `sk-ant-...` from console.anthropic.com | Phase 5 (task 5.01) | Pending |
|
| **Anthropic API Key** | `sk-ant-...` from console.anthropic.com | Phase 5 (task 5.01) | Pending |
|
||||||
| **OpenAI API Key** | `sk-...` from platform.openai.com | Phase 5 (task 5.01) | Pending |
|
| **OpenAI API Key** | `sk-...` from platform.openai.com | Phase 5 (task 5.01) | Pending |
|
||||||
| **Authentik Admin** | Login to create OAuth app at :9100 | Phase 6 (task 6.01) | ✅ Done |
|
| **Authentik Admin** | Login to create OAuth app at :9100 | Phase 6 (task 6.01) | ✅ Done |
|
||||||
| **MinIO Credentials** | Access key + secret key for :9003 | Phase 7 (task 7.04) | ✅ Done |
|
| **MinIO Credentials** | Access key + secret key for :9003 | Phase 7 (task 7.04) | ✅ Done |
|
||||||
| **PostgreSQL** | Database + password | Phase 7 (task 7.01) | ✅ Done |
|
| **PostgreSQL** | Database + password | Phase 7 (task 7.01) | ✅ Done |
|
||||||
| **Gitea Actions Runner** | Registration token from Gitea admin | Phase 10 (task 10.01) | Pending |
|
| **Gitea Actions Runner** | Registration token from Gitea admin | Phase 10 (task 10.01) | Pending |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,45 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Session — 2026-02-27 night (GitHub Copilot - Claude Opus 4.6)
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
Continued Phase 3 refinements. Picked task 3.02 (HEAVY) — Registratura bidirectional integration and UX improvements.
|
||||||
|
|
||||||
|
### Completed
|
||||||
|
|
||||||
|
- **3.02 ✅ Registratura — Integrare și UX:**
|
||||||
|
- **Dynamic document types:** DocumentType changed from enum to string-based. Default types include "Apel telefonic" and "Videoconferință". Inline "Tip nou" input with Plus button auto-creates Tag Manager tags under category "document-type". Form select pulls from both defaults and Tag Manager.
|
||||||
|
- **Bidirectional Address Book:** Sender/Recipient/Assignee autocomplete shows "Creează contact" button when typed name doesn't match any existing contact. QuickContactDialog popup creates contact in Address Book with Name (required), Phone (optional), Email (optional).
|
||||||
|
- **Simplified status:** Removed Status dropdown. Replaced with Switch toggle — default is "Deschis" (open). Toggle to "Închis" when done. No "deschis" option in UI, everything is implicitly open.
|
||||||
|
- **Internal deadline tooltip:** Added Info icon tooltips on "Termen limită intern" (explaining it's not a legal deadline), "Închis" (explaining default behavior), and "Responsabil" (explaining ERP prep).
|
||||||
|
- **Responsabil (Assignee) field:** New field with contact autocomplete + quick-create. Separate `assignee` and `assigneeContactId` on RegistryEntry type. Shown in registry table as "Resp." column with User icon.
|
||||||
|
- **Entry threads & branching:** New `threadParentId` field on RegistryEntry. Thread parent selector with search. ThreadView component showing parent → current → children tree with direction badges, siblings (branches), and click-to-navigate. Thread icon (GitBranch) in registry table. Supports one-to-many branching (one entry generates multiple outgoing replies).
|
||||||
|
|
||||||
|
### Files Created
|
||||||
|
|
||||||
|
- `src/modules/registratura/components/quick-contact-dialog.tsx` — Rapid contact creation popup from Registratura
|
||||||
|
- `src/modules/registratura/components/thread-view.tsx` — Thread tree visualization (parent, current, siblings, children)
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
- `src/modules/registratura/types.ts` — Dynamic DocumentType, DEFAULT_DOC_TYPE_LABELS, new fields (assignee, assigneeContactId, threadParentId)
|
||||||
|
- `src/modules/registratura/index.ts` — Export new constants
|
||||||
|
- `src/modules/registratura/components/registry-entry-form.tsx` — Complete rewrite: dynamic doc types, contact quick-create, Switch status, Responsabil field, thread parent selector, tooltips
|
||||||
|
- `src/modules/registratura/components/registratura-module.tsx` — Wired useTags + useContacts, handleCreateContact + handleCreateDocType callbacks, thread navigation
|
||||||
|
- `src/modules/registratura/components/registry-table.tsx` — Dynamic doc type labels, Resp. column, thread icon
|
||||||
|
- `src/modules/registratura/components/registry-filters.tsx` — Uses DEFAULT_DOC_TYPE_LABELS from types.ts
|
||||||
|
- `ROADMAP.md` — Marked 3.02 as done
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- Existing data is backward-compatible: old entries without `assignee`, `threadParentId` render fine (fields are optional)
|
||||||
|
- Quick contact creation sets type to "collaborator" and adds note "Creat automat din Registratură"
|
||||||
|
- DocumentType is now `string` but retains defaults via `DEFAULT_DOCUMENT_TYPES` array
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Session — 2026-02-27 evening (GitHub Copilot - Claude Opus 4.6)
|
## Session — 2026-02-27 evening (GitHub Copilot - Claude Opus 4.6)
|
||||||
|
|
||||||
### Context
|
### Context
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { UserPlus } from "lucide-react";
|
||||||
|
import { Input } from "@/shared/components/ui/input";
|
||||||
|
import { Label } from "@/shared/components/ui/label";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/shared/components/ui/dialog";
|
||||||
|
|
||||||
|
interface QuickContactDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
/** Pre-filled name from the text the user typed */
|
||||||
|
initialName: string;
|
||||||
|
onConfirm: (data: { name: string; phone: string; email: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rapid popup for creating a new Address Book contact from Registratura.
|
||||||
|
* Only requires Name; Phone and Email are optional.
|
||||||
|
*/
|
||||||
|
export function QuickContactDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
initialName,
|
||||||
|
onConfirm,
|
||||||
|
}: QuickContactDialogProps) {
|
||||||
|
const [name, setName] = useState(initialName);
|
||||||
|
const [phone, setPhone] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
|
||||||
|
// Reset when dialog opens with new name
|
||||||
|
const handleOpenChange = (o: boolean) => {
|
||||||
|
if (o) {
|
||||||
|
setName(initialName);
|
||||||
|
setPhone("");
|
||||||
|
setEmail("");
|
||||||
|
}
|
||||||
|
onOpenChange(o);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim()) return;
|
||||||
|
onConfirm({ name: name.trim(), phone: phone.trim(), email: email.trim() });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<UserPlus className="h-4 w-4" />
|
||||||
|
Contact nou rapid
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label>Nume *</Label>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label>Telefon</Label>
|
||||||
|
<Input
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="Opțional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Email</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="Opțional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Anulează
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={!name.trim()}>
|
||||||
|
Creează contact
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,51 +1,140 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo, useCallback } from "react";
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from "lucide-react";
|
||||||
import { Button } from '@/shared/components/ui/button';
|
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/components/ui/tabs';
|
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
Card,
|
||||||
} from '@/shared/components/ui/dialog';
|
CardContent,
|
||||||
import { useRegistry } from '../hooks/use-registry';
|
CardHeader,
|
||||||
import { RegistryFilters } from './registry-filters';
|
CardTitle,
|
||||||
import { RegistryTable } from './registry-table';
|
} from "@/shared/components/ui/card";
|
||||||
import { RegistryEntryForm } from './registry-entry-form';
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { DeadlineDashboard } from './deadline-dashboard';
|
import {
|
||||||
import { getOverdueDays } from '../services/registry-service';
|
Tabs,
|
||||||
import { aggregateDeadlines } from '../services/deadline-service';
|
TabsContent,
|
||||||
import type { RegistryEntry, DeadlineResolution } from '../types';
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "@/shared/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/shared/components/ui/dialog";
|
||||||
|
import { useRegistry } from "../hooks/use-registry";
|
||||||
|
import { useContacts } from "@/modules/address-book/hooks/use-contacts";
|
||||||
|
import { useTags } from "@/core/tagging";
|
||||||
|
import { RegistryFilters } from "./registry-filters";
|
||||||
|
import { RegistryTable } from "./registry-table";
|
||||||
|
import { RegistryEntryForm } from "./registry-entry-form";
|
||||||
|
import { DeadlineDashboard } from "./deadline-dashboard";
|
||||||
|
import { getOverdueDays } from "../services/registry-service";
|
||||||
|
import { aggregateDeadlines } from "../services/deadline-service";
|
||||||
|
import type { RegistryEntry, DeadlineResolution } from "../types";
|
||||||
|
import type { AddressContact } from "@/modules/address-book/types";
|
||||||
|
|
||||||
type ViewMode = 'list' | 'add' | 'edit';
|
type ViewMode = "list" | "add" | "edit";
|
||||||
|
|
||||||
export function RegistraturaModule() {
|
export function RegistraturaModule() {
|
||||||
const {
|
const {
|
||||||
entries, allEntries, loading, filters, updateFilter,
|
entries,
|
||||||
addEntry, updateEntry, removeEntry, closeEntry,
|
allEntries,
|
||||||
addDeadline, resolveDeadline, removeDeadline,
|
loading,
|
||||||
|
filters,
|
||||||
|
updateFilter,
|
||||||
|
addEntry,
|
||||||
|
updateEntry,
|
||||||
|
removeEntry,
|
||||||
|
closeEntry,
|
||||||
|
addDeadline,
|
||||||
|
resolveDeadline,
|
||||||
|
removeDeadline,
|
||||||
} = useRegistry();
|
} = useRegistry();
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
const { addContact } = useContacts();
|
||||||
|
const { createTag } = useTags("document-type");
|
||||||
|
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
||||||
const [editingEntry, setEditingEntry] = useState<RegistryEntry | null>(null);
|
const [editingEntry, setEditingEntry] = useState<RegistryEntry | null>(null);
|
||||||
const [closingId, setClosingId] = useState<string | null>(null);
|
const [closingId, setClosingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleAdd = async (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => {
|
// ── Bidirectional Address Book integration ──
|
||||||
|
const handleCreateContact = useCallback(
|
||||||
|
async (data: {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
}): Promise<AddressContact | undefined> => {
|
||||||
|
try {
|
||||||
|
const contact = await addContact({
|
||||||
|
name: data.name,
|
||||||
|
company: "",
|
||||||
|
type: "collaborator",
|
||||||
|
email: data.email,
|
||||||
|
email2: "",
|
||||||
|
phone: data.phone,
|
||||||
|
phone2: "",
|
||||||
|
address: "",
|
||||||
|
department: "",
|
||||||
|
role: "",
|
||||||
|
website: "",
|
||||||
|
projectIds: [],
|
||||||
|
contactPersons: [],
|
||||||
|
tags: [],
|
||||||
|
notes: "Creat automat din Registratură",
|
||||||
|
visibility: "all",
|
||||||
|
});
|
||||||
|
return contact;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addContact],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Bidirectional Tag Manager integration ──
|
||||||
|
const handleCreateDocType = useCallback(
|
||||||
|
async (label: string) => {
|
||||||
|
try {
|
||||||
|
await createTag({
|
||||||
|
label,
|
||||||
|
category: "document-type",
|
||||||
|
scope: "global",
|
||||||
|
color: "#64748b",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// tag may already exist — ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[createTag],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAdd = async (
|
||||||
|
data: Omit<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">,
|
||||||
|
) => {
|
||||||
await addEntry(data);
|
await addEntry(data);
|
||||||
setViewMode('list');
|
setViewMode("list");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (entry: RegistryEntry) => {
|
const handleEdit = (entry: RegistryEntry) => {
|
||||||
setEditingEntry(entry);
|
setEditingEntry(entry);
|
||||||
setViewMode('edit');
|
setViewMode("edit");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdate = async (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => {
|
const handleNavigateEntry = (entry: RegistryEntry) => {
|
||||||
|
setEditingEntry(entry);
|
||||||
|
setViewMode("edit");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async (
|
||||||
|
data: Omit<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">,
|
||||||
|
) => {
|
||||||
if (!editingEntry) return;
|
if (!editingEntry) return;
|
||||||
await updateEntry(editingEntry.id, data);
|
await updateEntry(editingEntry.id, data);
|
||||||
setEditingEntry(null);
|
setEditingEntry(null);
|
||||||
setViewMode('list');
|
setViewMode("list");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
@@ -69,7 +158,7 @@ export function RegistraturaModule() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setViewMode('list');
|
setViewMode("list");
|
||||||
setEditingEntry(null);
|
setEditingEntry(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,24 +173,34 @@ export function RegistraturaModule() {
|
|||||||
await resolveDeadline(entryId, deadlineId, resolution, note);
|
await resolveDeadline(entryId, deadlineId, resolution, note);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddChainedDeadline = async (entryId: string, typeId: string, startDate: string, parentId: string) => {
|
const handleAddChainedDeadline = async (
|
||||||
|
entryId: string,
|
||||||
|
typeId: string,
|
||||||
|
startDate: string,
|
||||||
|
parentId: string,
|
||||||
|
) => {
|
||||||
await addDeadline(entryId, typeId, startDate, parentId);
|
await addDeadline(entryId, typeId, startDate, parentId);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
const total = allEntries.length;
|
const total = allEntries.length;
|
||||||
const open = allEntries.filter((e) => e.status === 'deschis').length;
|
const open = allEntries.filter((e) => e.status === "deschis").length;
|
||||||
const overdue = allEntries.filter((e) => {
|
const overdue = allEntries.filter((e) => {
|
||||||
if (e.status !== 'deschis') return false;
|
if (e.status !== "deschis") return false;
|
||||||
const days = getOverdueDays(e.deadline);
|
const days = getOverdueDays(e.deadline);
|
||||||
return days !== null && days > 0;
|
return days !== null && days > 0;
|
||||||
}).length;
|
}).length;
|
||||||
const intrat = allEntries.filter((e) => e.direction === 'intrat').length;
|
const intrat = allEntries.filter((e) => e.direction === "intrat").length;
|
||||||
|
|
||||||
const deadlineStats = useMemo(() => aggregateDeadlines(allEntries), [allEntries]);
|
const deadlineStats = useMemo(
|
||||||
|
() => aggregateDeadlines(allEntries),
|
||||||
|
[allEntries],
|
||||||
|
);
|
||||||
const urgentDeadlines = deadlineStats.urgent + deadlineStats.overdue;
|
const urgentDeadlines = deadlineStats.urgent + deadlineStats.overdue;
|
||||||
|
|
||||||
const closingEntry = closingId ? allEntries.find((e) => e.id === closingId) : null;
|
const closingEntry = closingId
|
||||||
|
? allEntries.find((e) => e.id === closingId)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue="registru">
|
<Tabs defaultValue="registru">
|
||||||
@@ -110,7 +209,10 @@ export function RegistraturaModule() {
|
|||||||
<TabsTrigger value="termene">
|
<TabsTrigger value="termene">
|
||||||
Termene legale
|
Termene legale
|
||||||
{urgentDeadlines > 0 && (
|
{urgentDeadlines > 0 && (
|
||||||
<Badge variant="destructive" className="ml-1.5 text-[10px] px-1.5 py-0">
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="ml-1.5 text-[10px] px-1.5 py-0"
|
||||||
|
>
|
||||||
{urgentDeadlines}
|
{urgentDeadlines}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -123,15 +225,19 @@ export function RegistraturaModule() {
|
|||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<StatCard label="Total" value={total} />
|
<StatCard label="Total" value={total} />
|
||||||
<StatCard label="Deschise" value={open} />
|
<StatCard label="Deschise" value={open} />
|
||||||
<StatCard label="Depășite" value={overdue} variant={overdue > 0 ? 'destructive' : undefined} />
|
<StatCard
|
||||||
|
label="Depășite"
|
||||||
|
value={overdue}
|
||||||
|
variant={overdue > 0 ? "destructive" : undefined}
|
||||||
|
/>
|
||||||
<StatCard label="Intrate" value={intrat} />
|
<StatCard label="Intrate" value={intrat} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{viewMode === 'list' && (
|
{viewMode === "list" && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<RegistryFilters filters={filters} onUpdate={updateFilter} />
|
<RegistryFilters filters={filters} onUpdate={updateFilter} />
|
||||||
<Button onClick={() => setViewMode('add')} className="shrink-0">
|
<Button onClick={() => setViewMode("add")} className="shrink-0">
|
||||||
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
|
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,12 +258,14 @@ export function RegistraturaModule() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{viewMode === 'add' && (
|
{viewMode === "add" && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
Înregistrare nouă
|
Înregistrare nouă
|
||||||
<Badge variant="outline" className="text-xs">Nr. auto</Badge>
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Nr. auto
|
||||||
|
</Badge>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -165,12 +273,14 @@ export function RegistraturaModule() {
|
|||||||
allEntries={allEntries}
|
allEntries={allEntries}
|
||||||
onSubmit={handleAdd}
|
onSubmit={handleAdd}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
|
onCreateContact={handleCreateContact}
|
||||||
|
onCreateDocType={handleCreateDocType}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{viewMode === 'edit' && editingEntry && (
|
{viewMode === "edit" && editingEntry && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Editare — {editingEntry.number}</CardTitle>
|
<CardTitle>Editare — {editingEntry.number}</CardTitle>
|
||||||
@@ -181,26 +291,40 @@ export function RegistraturaModule() {
|
|||||||
allEntries={allEntries}
|
allEntries={allEntries}
|
||||||
onSubmit={handleUpdate}
|
onSubmit={handleUpdate}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
|
onCreateContact={handleCreateContact}
|
||||||
|
onCreateDocType={handleCreateDocType}
|
||||||
|
onNavigateEntry={handleNavigateEntry}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Close confirmation dialog */}
|
{/* Close confirmation dialog */}
|
||||||
<Dialog open={closingId !== null} onOpenChange={(open) => { if (!open) setClosingId(null); }}>
|
<Dialog
|
||||||
|
open={closingId !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setClosingId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Închide înregistrarea</DialogTitle>
|
<DialogTitle>Închide înregistrarea</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
Această înregistrare are {closingEntry?.linkedEntryIds?.length ?? 0} înregistrări legate.
|
Această înregistrare are{" "}
|
||||||
Vrei să le închizi și pe acestea?
|
{closingEntry?.linkedEntryIds?.length ?? 0} înregistrări
|
||||||
|
legate. Vrei să le închizi și pe acestea?
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setClosingId(null)}>Anulează</Button>
|
<Button variant="outline" onClick={() => setClosingId(null)}>
|
||||||
<Button variant="secondary" onClick={() => handleCloseConfirm(false)}>
|
Anulează
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => handleCloseConfirm(false)}
|
||||||
|
>
|
||||||
Doar aceasta
|
Doar aceasta
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => handleCloseConfirm(true)}>
|
<Button onClick={() => handleCloseConfirm(true)}>
|
||||||
@@ -223,12 +347,22 @@ export function RegistraturaModule() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ label, value, variant }: { label: string; value: number; variant?: 'destructive' }) {
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
variant,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
variant?: "destructive";
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
<p className={`text-2xl font-bold ${variant === 'destructive' && value > 0 ? 'text-destructive' : ''}`}>
|
<p
|
||||||
|
className={`text-2xl font-bold ${variant === "destructive" && value > 0 ? "text-destructive" : ""}`}
|
||||||
|
>
|
||||||
{value}
|
{value}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,22 +1,31 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo, useRef } from "react";
|
import { useState, useMemo, useRef, useCallback } from "react";
|
||||||
import { Paperclip, X, Clock, Plus } from "lucide-react";
|
import {
|
||||||
|
Paperclip,
|
||||||
|
X,
|
||||||
|
Clock,
|
||||||
|
Plus,
|
||||||
|
UserPlus,
|
||||||
|
Info,
|
||||||
|
GitBranch,
|
||||||
|
} from "lucide-react";
|
||||||
import type { CompanyId } from "@/core/auth/types";
|
import type { CompanyId } from "@/core/auth/types";
|
||||||
import type {
|
import type {
|
||||||
RegistryEntry,
|
RegistryEntry,
|
||||||
RegistryDirection,
|
RegistryDirection,
|
||||||
RegistryStatus,
|
|
||||||
DocumentType,
|
DocumentType,
|
||||||
RegistryAttachment,
|
RegistryAttachment,
|
||||||
TrackedDeadline,
|
TrackedDeadline,
|
||||||
DeadlineResolution,
|
DeadlineResolution,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import { DEFAULT_DOC_TYPE_LABELS } from "../types";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
import { Label } from "@/shared/components/ui/label";
|
import { Label } from "@/shared/components/ui/label";
|
||||||
import { Textarea } from "@/shared/components/ui/textarea";
|
import { Textarea } from "@/shared/components/ui/textarea";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
import { Switch } from "@/shared/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -24,11 +33,21 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select";
|
} from "@/shared/components/ui/select";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/components/ui/tooltip";
|
||||||
import { useContacts } from "@/modules/address-book/hooks/use-contacts";
|
import { useContacts } from "@/modules/address-book/hooks/use-contacts";
|
||||||
|
import { useTags } from "@/core/tagging";
|
||||||
|
import type { AddressContact } from "@/modules/address-book/types";
|
||||||
import { v4 as uuid } from "uuid";
|
import { v4 as uuid } from "uuid";
|
||||||
import { DeadlineCard } from "./deadline-card";
|
import { DeadlineCard } from "./deadline-card";
|
||||||
import { DeadlineAddDialog } from "./deadline-add-dialog";
|
import { DeadlineAddDialog } from "./deadline-add-dialog";
|
||||||
import { DeadlineResolveDialog } from "./deadline-resolve-dialog";
|
import { DeadlineResolveDialog } from "./deadline-resolve-dialog";
|
||||||
|
import { QuickContactDialog } from "./quick-contact-dialog";
|
||||||
|
import { ThreadView } from "./thread-view";
|
||||||
import {
|
import {
|
||||||
createTrackedDeadline,
|
createTrackedDeadline,
|
||||||
resolveDeadline as resolveDeadlineFn,
|
resolveDeadline as resolveDeadlineFn,
|
||||||
@@ -42,35 +61,55 @@ interface RegistryEntryFormProps {
|
|||||||
data: Omit<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">,
|
data: Omit<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">,
|
||||||
) => void;
|
) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
/** Callback to create a new Address Book contact */
|
||||||
|
onCreateContact?: (data: {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
}) => Promise<AddressContact | undefined>;
|
||||||
|
/** Callback to create a new document type tag in Tag Manager */
|
||||||
|
onCreateDocType?: (label: string) => Promise<void>;
|
||||||
|
/** Navigate to an entry (for thread clicks) */
|
||||||
|
onNavigateEntry?: (entry: RegistryEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DOC_TYPE_LABELS: Record<DocumentType, string> = {
|
|
||||||
contract: "Contract",
|
|
||||||
oferta: "Ofertă",
|
|
||||||
factura: "Factură",
|
|
||||||
scrisoare: "Scrisoare",
|
|
||||||
aviz: "Aviz",
|
|
||||||
"nota-de-comanda": "Notă de comandă",
|
|
||||||
raport: "Raport",
|
|
||||||
cerere: "Cerere",
|
|
||||||
altele: "Altele",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function RegistryEntryForm({
|
export function RegistryEntryForm({
|
||||||
initial,
|
initial,
|
||||||
allEntries,
|
allEntries,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
onCreateContact,
|
||||||
|
onCreateDocType,
|
||||||
|
onNavigateEntry,
|
||||||
}: RegistryEntryFormProps) {
|
}: RegistryEntryFormProps) {
|
||||||
const { allContacts } = useContacts();
|
const { allContacts, refresh: refreshContacts } = useContacts();
|
||||||
|
const { tags: docTypeTags } = useTags("document-type");
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// ── Build dynamic doc type list from defaults + Tag Manager ──
|
||||||
|
const allDocTypes = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
// Add defaults
|
||||||
|
for (const [key, label] of Object.entries(DEFAULT_DOC_TYPE_LABELS)) {
|
||||||
|
map.set(key, label);
|
||||||
|
}
|
||||||
|
// Add from Tag Manager (document-type category)
|
||||||
|
for (const tag of docTypeTags) {
|
||||||
|
const key = tag.label.toLowerCase().replace(/\s+/g, "-");
|
||||||
|
if (!map.has(key)) {
|
||||||
|
map.set(key, tag.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [docTypeTags]);
|
||||||
|
|
||||||
const [direction, setDirection] = useState<RegistryDirection>(
|
const [direction, setDirection] = useState<RegistryDirection>(
|
||||||
initial?.direction ?? "intrat",
|
initial?.direction ?? "intrat",
|
||||||
);
|
);
|
||||||
const [documentType, setDocumentType] = useState<DocumentType>(
|
const [documentType, setDocumentType] = useState<DocumentType>(
|
||||||
initial?.documentType ?? "scrisoare",
|
initial?.documentType ?? "scrisoare",
|
||||||
);
|
);
|
||||||
|
const [customDocType, setCustomDocType] = useState("");
|
||||||
const [subject, setSubject] = useState(initial?.subject ?? "");
|
const [subject, setSubject] = useState(initial?.subject ?? "");
|
||||||
const [date, setDate] = useState(
|
const [date, setDate] = useState(
|
||||||
initial?.date ?? new Date().toISOString().slice(0, 10),
|
initial?.date ?? new Date().toISOString().slice(0, 10),
|
||||||
@@ -86,10 +125,15 @@ export function RegistryEntryForm({
|
|||||||
const [company, setCompany] = useState<CompanyId>(
|
const [company, setCompany] = useState<CompanyId>(
|
||||||
initial?.company ?? "beletage",
|
initial?.company ?? "beletage",
|
||||||
);
|
);
|
||||||
const [status, setStatus] = useState<RegistryStatus>(
|
const [isClosed, setIsClosed] = useState(initial?.status === "inchis");
|
||||||
initial?.status ?? "deschis",
|
|
||||||
);
|
|
||||||
const [deadline, setDeadline] = useState(initial?.deadline ?? "");
|
const [deadline, setDeadline] = useState(initial?.deadline ?? "");
|
||||||
|
const [assignee, setAssignee] = useState(initial?.assignee ?? "");
|
||||||
|
const [assigneeContactId, setAssigneeContactId] = useState(
|
||||||
|
initial?.assigneeContactId ?? "",
|
||||||
|
);
|
||||||
|
const [threadParentId, setThreadParentId] = useState(
|
||||||
|
initial?.threadParentId ?? "",
|
||||||
|
);
|
||||||
const [notes, setNotes] = useState(initial?.notes ?? "");
|
const [notes, setNotes] = useState(initial?.notes ?? "");
|
||||||
const [linkedEntryIds, setLinkedEntryIds] = useState<string[]>(
|
const [linkedEntryIds, setLinkedEntryIds] = useState<string[]>(
|
||||||
initial?.linkedEntryIds ?? [],
|
initial?.linkedEntryIds ?? [],
|
||||||
@@ -101,12 +145,20 @@ export function RegistryEntryForm({
|
|||||||
initial?.trackedDeadlines ?? [],
|
initial?.trackedDeadlines ?? [],
|
||||||
);
|
);
|
||||||
const [linkedSearch, setLinkedSearch] = useState("");
|
const [linkedSearch, setLinkedSearch] = useState("");
|
||||||
|
const [threadSearch, setThreadSearch] = useState("");
|
||||||
|
|
||||||
// ── Deadline dialogs ──
|
// ── Deadline dialogs ──
|
||||||
const [deadlineAddOpen, setDeadlineAddOpen] = useState(false);
|
const [deadlineAddOpen, setDeadlineAddOpen] = useState(false);
|
||||||
const [resolvingDeadline, setResolvingDeadline] =
|
const [resolvingDeadline, setResolvingDeadline] =
|
||||||
useState<TrackedDeadline | null>(null);
|
useState<TrackedDeadline | null>(null);
|
||||||
|
|
||||||
|
// ── Quick contact creation ──
|
||||||
|
const [quickContactOpen, setQuickContactOpen] = useState(false);
|
||||||
|
const [quickContactField, setQuickContactField] = useState<
|
||||||
|
"sender" | "recipient" | "assignee"
|
||||||
|
>("sender");
|
||||||
|
const [quickContactName, setQuickContactName] = useState("");
|
||||||
|
|
||||||
const handleAddDeadline = (
|
const handleAddDeadline = (
|
||||||
typeId: string,
|
typeId: string,
|
||||||
startDate: string,
|
startDate: string,
|
||||||
@@ -127,7 +179,6 @@ export function RegistryEntryForm({
|
|||||||
prev.map((d) => (d.id === resolved.id ? resolved : d)),
|
prev.map((d) => (d.id === resolved.id ? resolved : d)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle chain
|
|
||||||
if (chainNext) {
|
if (chainNext) {
|
||||||
const def = getDeadlineType(resolvingDeadline.typeId);
|
const def = getDeadlineType(resolvingDeadline.typeId);
|
||||||
if (def?.chainNextTypeId) {
|
if (def?.chainNextTypeId) {
|
||||||
@@ -147,33 +198,86 @@ export function RegistryEntryForm({
|
|||||||
setTrackedDeadlines((prev) => prev.filter((d) => d.id !== deadlineId));
|
setTrackedDeadlines((prev) => prev.filter((d) => d.id !== deadlineId));
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Sender/Recipient autocomplete suggestions ──
|
// ── Contact autocomplete ──
|
||||||
const [senderFocused, setSenderFocused] = useState(false);
|
const [senderFocused, setSenderFocused] = useState(false);
|
||||||
const [recipientFocused, setRecipientFocused] = useState(false);
|
const [recipientFocused, setRecipientFocused] = useState(false);
|
||||||
|
const [assigneeFocused, setAssigneeFocused] = useState(false);
|
||||||
|
|
||||||
const senderSuggestions = useMemo(() => {
|
const filterContacts = useCallback(
|
||||||
if (!sender || sender.length < 2) return [];
|
(query: string) => {
|
||||||
const q = sender.toLowerCase();
|
if (!query || query.length < 2) return [];
|
||||||
return allContacts
|
const q = query.toLowerCase();
|
||||||
.filter(
|
return allContacts
|
||||||
(c) =>
|
.filter(
|
||||||
c.name.toLowerCase().includes(q) ||
|
(c) =>
|
||||||
c.company.toLowerCase().includes(q),
|
c.name.toLowerCase().includes(q) ||
|
||||||
)
|
c.company.toLowerCase().includes(q),
|
||||||
.slice(0, 5);
|
)
|
||||||
}, [allContacts, sender]);
|
.slice(0, 5);
|
||||||
|
},
|
||||||
|
[allContacts],
|
||||||
|
);
|
||||||
|
|
||||||
const recipientSuggestions = useMemo(() => {
|
const senderSuggestions = useMemo(
|
||||||
if (!recipient || recipient.length < 2) return [];
|
() => filterContacts(sender),
|
||||||
const q = recipient.toLowerCase();
|
[filterContacts, sender],
|
||||||
return allContacts
|
);
|
||||||
.filter(
|
const recipientSuggestions = useMemo(
|
||||||
(c) =>
|
() => filterContacts(recipient),
|
||||||
c.name.toLowerCase().includes(q) ||
|
[filterContacts, recipient],
|
||||||
c.company.toLowerCase().includes(q),
|
);
|
||||||
)
|
const assigneeSuggestions = useMemo(
|
||||||
.slice(0, 5);
|
() => filterContacts(assignee),
|
||||||
}, [allContacts, recipient]);
|
[filterContacts, assignee],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Quick contact creation handler ──
|
||||||
|
const openQuickContact = (
|
||||||
|
field: "sender" | "recipient" | "assignee",
|
||||||
|
name: string,
|
||||||
|
) => {
|
||||||
|
setQuickContactField(field);
|
||||||
|
setQuickContactName(name);
|
||||||
|
setQuickContactOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickContactConfirm = async (data: {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
}) => {
|
||||||
|
if (!onCreateContact) return;
|
||||||
|
const contact = await onCreateContact(data);
|
||||||
|
if (contact) {
|
||||||
|
const displayName = contact.company
|
||||||
|
? `${contact.name} (${contact.company})`
|
||||||
|
: contact.name;
|
||||||
|
if (quickContactField === "sender") {
|
||||||
|
setSender(displayName);
|
||||||
|
setSenderContactId(contact.id);
|
||||||
|
} else if (quickContactField === "recipient") {
|
||||||
|
setRecipient(displayName);
|
||||||
|
setRecipientContactId(contact.id);
|
||||||
|
} else {
|
||||||
|
setAssignee(displayName);
|
||||||
|
setAssigneeContactId(contact.id);
|
||||||
|
}
|
||||||
|
await refreshContacts();
|
||||||
|
}
|
||||||
|
setQuickContactOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Custom doc type creation ──
|
||||||
|
const handleAddCustomDocType = async () => {
|
||||||
|
const label = customDocType.trim();
|
||||||
|
if (!label) return;
|
||||||
|
const key = label.toLowerCase().replace(/\s+/g, "-");
|
||||||
|
setDocumentType(key);
|
||||||
|
setCustomDocType("");
|
||||||
|
if (onCreateDocType) {
|
||||||
|
await onCreateDocType(label);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
@@ -215,8 +319,11 @@ export function RegistryEntryForm({
|
|||||||
recipient,
|
recipient,
|
||||||
recipientContactId: recipientContactId || undefined,
|
recipientContactId: recipientContactId || undefined,
|
||||||
company,
|
company,
|
||||||
status,
|
status: isClosed ? "inchis" : "deschis",
|
||||||
deadline: deadline || undefined,
|
deadline: deadline || undefined,
|
||||||
|
assignee: assignee || undefined,
|
||||||
|
assigneeContactId: assigneeContactId || undefined,
|
||||||
|
threadParentId: threadParentId || undefined,
|
||||||
linkedEntryIds,
|
linkedEntryIds,
|
||||||
attachments,
|
attachments,
|
||||||
trackedDeadlines:
|
trackedDeadlines:
|
||||||
@@ -227,8 +334,78 @@ export function RegistryEntryForm({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Contact autocomplete dropdown renderer ──
|
||||||
|
const renderContactDropdown = (
|
||||||
|
suggestions: AddressContact[],
|
||||||
|
focused: boolean,
|
||||||
|
fieldName: "sender" | "recipient" | "assignee",
|
||||||
|
currentValue: string,
|
||||||
|
onSelect: (c: AddressContact) => void,
|
||||||
|
) => {
|
||||||
|
if (!focused) return null;
|
||||||
|
|
||||||
|
const hasExactMatch = suggestions.some(
|
||||||
|
(c) =>
|
||||||
|
c.name.toLowerCase() === currentValue.toLowerCase() ||
|
||||||
|
`${c.name} (${c.company})`.toLowerCase() === currentValue.toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const showCreateButton =
|
||||||
|
currentValue.length >= 2 && !hasExactMatch && onCreateContact;
|
||||||
|
|
||||||
|
if (suggestions.length === 0 && !showCreateButton) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute z-10 mt-1 w-full rounded-md border bg-popover p-1 shadow-md">
|
||||||
|
{suggestions.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
|
||||||
|
onMouseDown={() => onSelect(c)}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{c.name}</span>
|
||||||
|
{c.company && (
|
||||||
|
<span className="ml-1 text-muted-foreground text-xs">
|
||||||
|
{c.company}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{showCreateButton && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent text-primary flex items-center gap-1.5 border-t mt-1 pt-1.5"
|
||||||
|
onMouseDown={() => openQuickContact(fieldName, currentValue)}
|
||||||
|
>
|
||||||
|
<UserPlus className="h-3.5 w-3.5" />
|
||||||
|
Creează contact "{currentValue}"
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Thread parent entry for display
|
||||||
|
const threadParent =
|
||||||
|
threadParentId && allEntries
|
||||||
|
? allEntries.find((e) => e.id === threadParentId)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Thread view (if editing an entry that's in a thread) */}
|
||||||
|
{initial &&
|
||||||
|
allEntries &&
|
||||||
|
(initial.threadParentId ||
|
||||||
|
allEntries.some((e) => e.threadParentId === initial.id)) && (
|
||||||
|
<ThreadView
|
||||||
|
entry={initial}
|
||||||
|
allEntries={allEntries}
|
||||||
|
onNavigate={onNavigateEntry}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Row 1: Direction + Document type + Date */}
|
{/* Row 1: Direction + Document type + Date */}
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -248,23 +425,48 @@ export function RegistryEntryForm({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Tip document</Label>
|
<Label>Tip document</Label>
|
||||||
<Select
|
<div className="mt-1">
|
||||||
value={documentType}
|
<Select
|
||||||
onValueChange={(v) => setDocumentType(v as DocumentType)}
|
value={allDocTypes.has(documentType) ? documentType : "altele"}
|
||||||
>
|
onValueChange={(v) => setDocumentType(v as DocumentType)}
|
||||||
<SelectTrigger className="mt-1">
|
>
|
||||||
<SelectValue />
|
<SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectValue />
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
{(
|
<SelectContent>
|
||||||
Object.entries(DOC_TYPE_LABELS) as [DocumentType, string][]
|
{Array.from(allDocTypes.entries()).map(([key, label]) => (
|
||||||
).map(([key, label]) => (
|
<SelectItem key={key} value={key}>
|
||||||
<SelectItem key={key} value={key}>
|
{label}
|
||||||
{label}
|
</SelectItem>
|
||||||
</SelectItem>
|
))}
|
||||||
))}
|
</SelectContent>
|
||||||
</SelectContent>
|
</Select>
|
||||||
</Select>
|
</div>
|
||||||
|
{/* Add custom type inline */}
|
||||||
|
<div className="mt-1.5 flex gap-1">
|
||||||
|
<Input
|
||||||
|
value={customDocType}
|
||||||
|
onChange={(e) => setCustomDocType(e.target.value)}
|
||||||
|
placeholder="Tip nou..."
|
||||||
|
className="text-xs h-7"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddCustomDocType();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2"
|
||||||
|
onClick={handleAddCustomDocType}
|
||||||
|
disabled={!customDocType.trim()}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Data</Label>
|
<Label>Data</Label>
|
||||||
@@ -288,8 +490,9 @@ export function RegistryEntryForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sender / Recipient with autocomplete */}
|
{/* Sender / Recipient with autocomplete + quick create */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{/* Sender */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Label>Expeditor</Label>
|
<Label>Expeditor</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -303,30 +506,19 @@ export function RegistryEntryForm({
|
|||||||
className="mt-1"
|
className="mt-1"
|
||||||
placeholder="Nume sau companie..."
|
placeholder="Nume sau companie..."
|
||||||
/>
|
/>
|
||||||
{senderFocused && senderSuggestions.length > 0 && (
|
{renderContactDropdown(
|
||||||
<div className="absolute z-10 mt-1 w-full rounded-md border bg-popover p-1 shadow-md">
|
senderSuggestions,
|
||||||
{senderSuggestions.map((c) => (
|
senderFocused,
|
||||||
<button
|
"sender",
|
||||||
key={c.id}
|
sender,
|
||||||
type="button"
|
(c) => {
|
||||||
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
|
setSender(c.company ? `${c.name} (${c.company})` : c.name);
|
||||||
onMouseDown={() => {
|
setSenderContactId(c.id);
|
||||||
setSender(c.company ? `${c.name} (${c.company})` : c.name);
|
setSenderFocused(false);
|
||||||
setSenderContactId(c.id);
|
},
|
||||||
setSenderFocused(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="font-medium">{c.name}</span>
|
|
||||||
{c.company && (
|
|
||||||
<span className="ml-1 text-muted-foreground text-xs">
|
|
||||||
{c.company}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Recipient */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Label>Destinatar</Label>
|
<Label>Destinatar</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -340,35 +532,63 @@ export function RegistryEntryForm({
|
|||||||
className="mt-1"
|
className="mt-1"
|
||||||
placeholder="Nume sau companie..."
|
placeholder="Nume sau companie..."
|
||||||
/>
|
/>
|
||||||
{recipientFocused && recipientSuggestions.length > 0 && (
|
{renderContactDropdown(
|
||||||
<div className="absolute z-10 mt-1 w-full rounded-md border bg-popover p-1 shadow-md">
|
recipientSuggestions,
|
||||||
{recipientSuggestions.map((c) => (
|
recipientFocused,
|
||||||
<button
|
"recipient",
|
||||||
key={c.id}
|
recipient,
|
||||||
type="button"
|
(c) => {
|
||||||
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
|
setRecipient(c.company ? `${c.name} (${c.company})` : c.name);
|
||||||
onMouseDown={() => {
|
setRecipientContactId(c.id);
|
||||||
setRecipient(
|
setRecipientFocused(false);
|
||||||
c.company ? `${c.name} (${c.company})` : c.name,
|
},
|
||||||
);
|
|
||||||
setRecipientContactId(c.id);
|
|
||||||
setRecipientFocused(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="font-medium">{c.name}</span>
|
|
||||||
{c.company && (
|
|
||||||
<span className="ml-1 text-muted-foreground text-xs">
|
|
||||||
{c.company}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Company + Status + Deadline */}
|
{/* Assignee (Responsabil) */}
|
||||||
|
<div className="relative">
|
||||||
|
<Label className="flex items-center gap-1.5">
|
||||||
|
Responsabil
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" className="max-w-xs">
|
||||||
|
<p className="text-xs">
|
||||||
|
Persoana din echipă responsabilă cu gestionarea acestei
|
||||||
|
înregistrări. Câmp pregătit pentru integrare viitoare cu ERP.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={assignee}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAssignee(e.target.value);
|
||||||
|
setAssigneeContactId("");
|
||||||
|
}}
|
||||||
|
onFocus={() => setAssigneeFocused(true)}
|
||||||
|
onBlur={() => setTimeout(() => setAssigneeFocused(false), 200)}
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="Persoana responsabilă..."
|
||||||
|
/>
|
||||||
|
{renderContactDropdown(
|
||||||
|
assigneeSuggestions,
|
||||||
|
assigneeFocused,
|
||||||
|
"assignee",
|
||||||
|
assignee,
|
||||||
|
(c) => {
|
||||||
|
setAssignee(c.company ? `${c.name} (${c.company})` : c.name);
|
||||||
|
setAssigneeContactId(c.id);
|
||||||
|
setAssigneeFocused(false);
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Company + Closed switch + Deadline */}
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>Companie</Label>
|
<Label>Companie</Label>
|
||||||
@@ -388,22 +608,47 @@ export function RegistryEntryForm({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Status</Label>
|
<Label className="flex items-center gap-1.5">
|
||||||
<Select
|
Închis
|
||||||
value={status}
|
<TooltipProvider>
|
||||||
onValueChange={(v) => setStatus(v as RegistryStatus)}
|
<Tooltip>
|
||||||
>
|
<TooltipTrigger asChild>
|
||||||
<SelectTrigger className="mt-1">
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
<SelectValue />
|
</TooltipTrigger>
|
||||||
</SelectTrigger>
|
<TooltipContent side="right" className="max-w-xs">
|
||||||
<SelectContent>
|
<p className="text-xs">
|
||||||
<SelectItem value="deschis">Deschis</SelectItem>
|
Implicit, toate înregistrările sunt deschise. Bifează doar
|
||||||
<SelectItem value="inchis">Închis</SelectItem>
|
când dosarul este finalizat.
|
||||||
</SelectContent>
|
</p>
|
||||||
</Select>
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</Label>
|
||||||
|
<div className="mt-2.5 flex items-center gap-2">
|
||||||
|
<Switch checked={isClosed} onCheckedChange={setIsClosed} />
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{isClosed ? "Închis" : "Deschis"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Termen limită</Label>
|
<Label className="flex items-center gap-1.5">
|
||||||
|
Termen limită intern
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" className="max-w-xs">
|
||||||
|
<p className="text-xs">
|
||||||
|
Termen intern pentru a răspunde sau a acționa pe această
|
||||||
|
înregistrare. Nu este termen legal — termenele legale se
|
||||||
|
adaugă separat mai jos.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
value={deadline}
|
value={deadline}
|
||||||
@@ -413,6 +658,105 @@ export function RegistryEntryForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Thread parent — reply to another entry */}
|
||||||
|
{allEntries && allEntries.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label className="flex items-center gap-1.5">
|
||||||
|
<GitBranch className="h-3.5 w-3.5" />
|
||||||
|
Răspuns la (Thread)
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" className="max-w-xs">
|
||||||
|
<p className="text-xs">
|
||||||
|
Leagă această înregistrare ca răspuns la alta, creând un fir
|
||||||
|
de conversație instituțională. O intrare poate genera mai
|
||||||
|
multe ieșiri (branching).
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</Label>
|
||||||
|
{threadParent && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-2 rounded border border-primary/30 bg-primary/5 px-2 py-1.5 text-sm">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
threadParent.direction === "intrat" ? "default" : "secondary"
|
||||||
|
}
|
||||||
|
className="text-[10px]"
|
||||||
|
>
|
||||||
|
{threadParent.direction === "intrat" ? "↓" : "↑"}
|
||||||
|
</Badge>
|
||||||
|
<span className="font-mono text-xs">{threadParent.number}</span>
|
||||||
|
<span className="truncate text-muted-foreground text-xs">
|
||||||
|
{threadParent.subject}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="ml-auto h-6 w-6 p-0"
|
||||||
|
onClick={() => setThreadParentId("")}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!threadParentId && (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
className="mt-1.5"
|
||||||
|
placeholder="Caută după număr sau subiect..."
|
||||||
|
value={threadSearch}
|
||||||
|
onChange={(e) => setThreadSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
{threadSearch.trim().length >= 2 && (
|
||||||
|
<div className="mt-1.5 max-h-32 overflow-y-auto space-y-1">
|
||||||
|
{allEntries
|
||||||
|
.filter((e) => {
|
||||||
|
if (e.id === initial?.id) return false;
|
||||||
|
const q = threadSearch.toLowerCase();
|
||||||
|
return (
|
||||||
|
e.number.toLowerCase().includes(q) ||
|
||||||
|
e.subject.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((e) => (
|
||||||
|
<button
|
||||||
|
key={e.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setThreadParentId(e.id);
|
||||||
|
setThreadSearch("");
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 rounded border px-2 py-1 text-xs transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
e.direction === "intrat" ? "default" : "secondary"
|
||||||
|
}
|
||||||
|
className="text-[9px] px-1 py-0"
|
||||||
|
>
|
||||||
|
{e.direction === "intrat" ? "↓" : "↑"}
|
||||||
|
</Badge>
|
||||||
|
<span className="font-mono">{e.number}</span>
|
||||||
|
<span className="truncate text-muted-foreground">
|
||||||
|
{e.subject.length > 40
|
||||||
|
? e.subject.slice(0, 40) + "…"
|
||||||
|
: e.subject}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Linked entries */}
|
{/* Linked entries */}
|
||||||
{allEntries && allEntries.length > 0 && (
|
{allEntries && allEntries.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
@@ -582,6 +926,14 @@ export function RegistryEntryForm({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
|
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Quick contact creation dialog */}
|
||||||
|
<QuickContactDialog
|
||||||
|
open={quickContactOpen}
|
||||||
|
onOpenChange={setQuickContactOpen}
|
||||||
|
initialName={quickContactName}
|
||||||
|
onConfirm={handleQuickContactConfirm}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,22 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { Search } from 'lucide-react';
|
import { Search } from "lucide-react";
|
||||||
import { Input } from '@/shared/components/ui/input';
|
import { Input } from "@/shared/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
import {
|
||||||
import type { RegistryFilters as Filters } from '../hooks/use-registry';
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select";
|
||||||
|
import type { RegistryFilters as Filters } from "../hooks/use-registry";
|
||||||
|
import { DEFAULT_DOC_TYPE_LABELS } from "../types";
|
||||||
|
|
||||||
interface RegistryFiltersProps {
|
interface RegistryFiltersProps {
|
||||||
filters: Filters;
|
filters: Filters;
|
||||||
onUpdate: <K extends keyof Filters>(key: K, value: Filters[K]) => void;
|
onUpdate: <K extends keyof Filters>(key: K, value: Filters[K]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DOC_TYPE_LABELS: Record<string, string> = {
|
|
||||||
contract: 'Contract',
|
|
||||||
oferta: 'Ofertă',
|
|
||||||
factura: 'Factură',
|
|
||||||
scrisoare: 'Scrisoare',
|
|
||||||
aviz: 'Aviz',
|
|
||||||
'nota-de-comanda': 'Notă de comandă',
|
|
||||||
raport: 'Raport',
|
|
||||||
cerere: 'Cerere',
|
|
||||||
altele: 'Altele',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) {
|
export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
@@ -30,12 +25,15 @@ export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) {
|
|||||||
<Input
|
<Input
|
||||||
placeholder="Caută după subiect, expeditor, număr..."
|
placeholder="Caută după subiect, expeditor, număr..."
|
||||||
value={filters.search}
|
value={filters.search}
|
||||||
onChange={(e) => onUpdate('search', e.target.value)}
|
onChange={(e) => onUpdate("search", e.target.value)}
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select value={filters.direction} onValueChange={(v) => onUpdate('direction', v as Filters['direction'])}>
|
<Select
|
||||||
|
value={filters.direction}
|
||||||
|
onValueChange={(v) => onUpdate("direction", v as Filters["direction"])}
|
||||||
|
>
|
||||||
<SelectTrigger className="w-[130px]">
|
<SelectTrigger className="w-[130px]">
|
||||||
<SelectValue placeholder="Direcție" />
|
<SelectValue placeholder="Direcție" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -46,19 +44,29 @@ export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={filters.documentType} onValueChange={(v) => onUpdate('documentType', v as Filters['documentType'])}>
|
<Select
|
||||||
|
value={filters.documentType}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
onUpdate("documentType", v as Filters["documentType"])
|
||||||
|
}
|
||||||
|
>
|
||||||
<SelectTrigger className="w-[160px]">
|
<SelectTrigger className="w-[160px]">
|
||||||
<SelectValue placeholder="Tip document" />
|
<SelectValue placeholder="Tip document" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Toate tipurile</SelectItem>
|
<SelectItem value="all">Toate tipurile</SelectItem>
|
||||||
{Object.entries(DOC_TYPE_LABELS).map(([key, label]) => (
|
{Object.entries(DEFAULT_DOC_TYPE_LABELS).map(([key, label]) => (
|
||||||
<SelectItem key={key} value={key}>{label}</SelectItem>
|
<SelectItem key={key} value={key}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={filters.status} onValueChange={(v) => onUpdate('status', v as Filters['status'])}>
|
<Select
|
||||||
|
value={filters.status}
|
||||||
|
onValueChange={(v) => onUpdate("status", v as Filters["status"])}
|
||||||
|
>
|
||||||
<SelectTrigger className="w-[130px]">
|
<SelectTrigger className="w-[130px]">
|
||||||
<SelectValue placeholder="Status" />
|
<SelectValue placeholder="Status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -69,7 +77,10 @@ export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={filters.company} onValueChange={(v) => onUpdate('company', v)}>
|
<Select
|
||||||
|
value={filters.company}
|
||||||
|
onValueChange={(v) => onUpdate("company", v)}
|
||||||
|
>
|
||||||
<SelectTrigger className="w-[170px]">
|
<SelectTrigger className="w-[170px]">
|
||||||
<SelectValue placeholder="Companie" />
|
<SelectValue placeholder="Companie" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { Pencil, Trash2, CheckCircle2, Link2, Clock } from 'lucide-react';
|
import {
|
||||||
import { Button } from '@/shared/components/ui/button';
|
Pencil,
|
||||||
import { Badge } from '@/shared/components/ui/badge';
|
Trash2,
|
||||||
import type { RegistryEntry, DocumentType } from '../types';
|
CheckCircle2,
|
||||||
import { getOverdueDays } from '../services/registry-service';
|
Link2,
|
||||||
import { cn } from '@/shared/lib/utils';
|
Clock,
|
||||||
|
GitBranch,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
import type { RegistryEntry } from "../types";
|
||||||
|
import { DEFAULT_DOC_TYPE_LABELS } from "../types";
|
||||||
|
import { getOverdueDays } from "../services/registry-service";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
interface RegistryTableProps {
|
interface RegistryTableProps {
|
||||||
entries: RegistryEntry[];
|
entries: RegistryEntry[];
|
||||||
@@ -16,30 +25,36 @@ interface RegistryTableProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DIRECTION_LABELS: Record<string, string> = {
|
const DIRECTION_LABELS: Record<string, string> = {
|
||||||
intrat: 'Intrat',
|
intrat: "Intrat",
|
||||||
iesit: 'Ieșit',
|
iesit: "Ieșit",
|
||||||
};
|
};
|
||||||
|
|
||||||
const DOC_TYPE_LABELS: Record<DocumentType, string> = {
|
/** Resolve doc type label from defaults or capitalize custom type */
|
||||||
contract: 'Contract',
|
function getDocTypeLabel(type: string): string {
|
||||||
oferta: 'Ofertă',
|
const label = DEFAULT_DOC_TYPE_LABELS[type];
|
||||||
factura: 'Factură',
|
if (label) return label;
|
||||||
scrisoare: 'Scrisoare',
|
// For custom types, capitalize first letter
|
||||||
aviz: 'Aviz',
|
return type.replace(/-/g, " ").replace(/^\w/, (c) => c.toUpperCase());
|
||||||
'nota-de-comanda': 'Notă comandă',
|
}
|
||||||
raport: 'Raport',
|
|
||||||
cerere: 'Cerere',
|
|
||||||
altele: 'Altele',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
deschis: 'Deschis',
|
deschis: "Deschis",
|
||||||
inchis: 'Închis',
|
inchis: "Închis",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RegistryTable({ entries, loading, onEdit, onDelete, onClose }: RegistryTableProps) {
|
export function RegistryTable({
|
||||||
|
entries,
|
||||||
|
loading,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onClose,
|
||||||
|
}: RegistryTableProps) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>;
|
return (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Se încarcă...
|
||||||
|
</p>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
@@ -62,6 +77,7 @@ export function RegistryTable({ entries, loading, onEdit, onDelete, onClose }: R
|
|||||||
<th className="px-3 py-2 text-left font-medium">Subiect</th>
|
<th className="px-3 py-2 text-left font-medium">Subiect</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Expeditor</th>
|
<th className="px-3 py-2 text-left font-medium">Expeditor</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Destinatar</th>
|
<th className="px-3 py-2 text-left font-medium">Destinatar</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Resp.</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Termen</th>
|
<th className="px-3 py-2 text-left font-medium">Termen</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Status</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>
|
<th className="px-3 py-2 text-right font-medium">Acțiuni</th>
|
||||||
@@ -69,29 +85,45 @@ export function RegistryTable({ entries, loading, onEdit, onDelete, onClose }: R
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{entries.map((entry) => {
|
{entries.map((entry) => {
|
||||||
const overdueDays = (entry.status === 'deschis' || !entry.status) ? getOverdueDays(entry.deadline) : null;
|
const overdueDays =
|
||||||
|
entry.status === "deschis" || !entry.status
|
||||||
|
? getOverdueDays(entry.deadline)
|
||||||
|
: null;
|
||||||
const isOverdue = overdueDays !== null && overdueDays > 0;
|
const isOverdue = overdueDays !== null && overdueDays > 0;
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-b transition-colors hover:bg-muted/20',
|
"border-b transition-colors hover:bg-muted/20",
|
||||||
isOverdue && 'bg-destructive/5'
|
isOverdue && "bg-destructive/5",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">{entry.number}</td>
|
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">
|
||||||
<td className="px-3 py-2 text-xs whitespace-nowrap">{formatDate(entry.date)}</td>
|
{entry.number}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-xs whitespace-nowrap">
|
||||||
|
{formatDate(entry.date)}
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<Badge
|
<Badge
|
||||||
variant={entry.direction === 'intrat' ? 'default' : 'secondary'}
|
variant={
|
||||||
|
entry.direction === "intrat" ? "default" : "secondary"
|
||||||
|
}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
{DIRECTION_LABELS[entry.direction] ?? entry.direction ?? '—'}
|
{DIRECTION_LABELS[entry.direction] ??
|
||||||
|
entry.direction ??
|
||||||
|
"—"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-xs">{DOC_TYPE_LABELS[entry.documentType] ?? entry.documentType ?? '—'}</td>
|
<td className="px-3 py-2 text-xs">
|
||||||
|
{getDocTypeLabel(entry.documentType)}
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2 max-w-[200px] truncate">
|
<td className="px-3 py-2 max-w-[200px] truncate">
|
||||||
{entry.subject}
|
{entry.subject}
|
||||||
|
{entry.threadParentId && (
|
||||||
|
<GitBranch className="ml-1 inline h-3 w-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
{(entry.linkedEntryIds ?? []).length > 0 && (
|
{(entry.linkedEntryIds ?? []).length > 0 && (
|
||||||
<Link2 className="ml-1 inline h-3 w-3 text-muted-foreground" />
|
<Link2 className="ml-1 inline h-3 w-3 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
@@ -107,17 +139,39 @@ export function RegistryTable({ entries, loading, onEdit, onDelete, onClose }: R
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 max-w-[130px] truncate">{entry.sender}</td>
|
<td className="px-3 py-2 max-w-[130px] truncate">
|
||||||
<td className="px-3 py-2 max-w-[130px] truncate">{entry.recipient}</td>
|
{entry.sender}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 max-w-[130px] truncate">
|
||||||
|
{entry.recipient}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 max-w-[100px] truncate text-xs">
|
||||||
|
{entry.assignee ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<User className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||||
|
{entry.assignee}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2 text-xs whitespace-nowrap">
|
<td className="px-3 py-2 text-xs whitespace-nowrap">
|
||||||
{entry.deadline ? (
|
{entry.deadline ? (
|
||||||
<span className={cn(isOverdue && 'font-medium text-destructive')}>
|
<span
|
||||||
|
className={cn(
|
||||||
|
isOverdue && "font-medium text-destructive",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{formatDate(entry.deadline)}
|
{formatDate(entry.deadline)}
|
||||||
{overdueDays !== null && overdueDays > 0 && (
|
{overdueDays !== null && overdueDays > 0 && (
|
||||||
<span className="ml-1 text-[10px]">({overdueDays}z depășit)</span>
|
<span className="ml-1 text-[10px]">
|
||||||
|
({overdueDays}z depășit)
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{overdueDays !== null && overdueDays < 0 && (
|
{overdueDays !== null && overdueDays < 0 && (
|
||||||
<span className="ml-1 text-[10px] text-muted-foreground">({Math.abs(overdueDays)}z)</span>
|
<span className="ml-1 text-[10px] text-muted-foreground">
|
||||||
|
({Math.abs(overdueDays)}z)
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -125,21 +179,39 @@ export function RegistryTable({ entries, loading, onEdit, onDelete, onClose }: R
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<Badge variant={entry.status === 'deschis' ? 'default' : 'outline'}>
|
<Badge
|
||||||
|
variant={entry.status === "deschis" ? "default" : "outline"}
|
||||||
|
>
|
||||||
{STATUS_LABELS[entry.status]}
|
{STATUS_LABELS[entry.status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
{entry.status === 'deschis' && (
|
{entry.status === "deschis" && (
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-green-600" onClick={() => onClose(entry.id)} title="Închide">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-green-600"
|
||||||
|
onClick={() => onClose(entry.id)}
|
||||||
|
title="Închide"
|
||||||
|
>
|
||||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onEdit(entry)}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => onEdit(entry)}
|
||||||
|
>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => onDelete(entry.id)}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
onClick={() => onDelete(entry.id)}
|
||||||
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,7 +227,11 @@ export function RegistryTable({ entries, loading, onEdit, onDelete, onClose }: R
|
|||||||
|
|
||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
try {
|
try {
|
||||||
return new Date(iso).toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
return new Date(iso).toLocaleDateString("ro-RO", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return iso;
|
return iso;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CornerDownRight, GitBranch } from "lucide-react";
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
import type { RegistryEntry } from "../types";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
interface ThreadViewProps {
|
||||||
|
/** The current entry being viewed */
|
||||||
|
entry: RegistryEntry;
|
||||||
|
/** All entries in the registry (to resolve references) */
|
||||||
|
allEntries: RegistryEntry[];
|
||||||
|
/** Click on an entry to navigate to it */
|
||||||
|
onNavigate?: (entry: RegistryEntry) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows thread relationships for a registry entry:
|
||||||
|
* - Parent entry (this is a reply to...)
|
||||||
|
* - Child entries (replies to this entry)
|
||||||
|
* Displays as an indented tree with direction badges.
|
||||||
|
*/
|
||||||
|
export function ThreadView({ entry, allEntries, onNavigate }: ThreadViewProps) {
|
||||||
|
// Find the parent entry (if this is a reply)
|
||||||
|
const parent = entry.threadParentId
|
||||||
|
? allEntries.find((e) => e.id === entry.threadParentId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Find child entries (replies to this entry)
|
||||||
|
const children = allEntries.filter((e) => e.threadParentId === entry.id);
|
||||||
|
|
||||||
|
// Find siblings (other replies to the same parent, excluding this one)
|
||||||
|
const siblings = entry.threadParentId
|
||||||
|
? allEntries.filter(
|
||||||
|
(e) => e.threadParentId === entry.threadParentId && e.id !== entry.id,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!parent && children.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-muted/20 p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
<GitBranch className="h-3.5 w-3.5" />
|
||||||
|
Fir conversație
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Parent */}
|
||||||
|
{parent && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
Răspuns la:
|
||||||
|
</p>
|
||||||
|
<ThreadEntryChip entry={parent} onNavigate={onNavigate} />
|
||||||
|
{/* Siblings — other branches from same parent */}
|
||||||
|
{siblings.length > 0 && (
|
||||||
|
<div className="ml-4 space-y-1">
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
Alte ramuri ({siblings.length}):
|
||||||
|
</p>
|
||||||
|
{siblings.map((s) => (
|
||||||
|
<ThreadEntryChip
|
||||||
|
key={s.id}
|
||||||
|
entry={s}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
dimmed
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current entry marker */}
|
||||||
|
<div className={cn("flex items-center gap-1.5", parent && "ml-4")}>
|
||||||
|
<CornerDownRight className="h-3 w-3 text-primary" />
|
||||||
|
<Badge variant="default" className="text-[10px]">
|
||||||
|
{entry.number}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground truncate max-w-[200px]">
|
||||||
|
{entry.subject}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Children — replies to this entry */}
|
||||||
|
{children.length > 0 && (
|
||||||
|
<div className={cn("ml-8 space-y-1", !parent && "ml-4")}>
|
||||||
|
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
Răspunsuri ({children.length}):
|
||||||
|
</p>
|
||||||
|
{children.map((child) => (
|
||||||
|
<ThreadEntryChip
|
||||||
|
key={child.id}
|
||||||
|
entry={child}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThreadEntryChip({
|
||||||
|
entry,
|
||||||
|
onNavigate,
|
||||||
|
dimmed,
|
||||||
|
}: {
|
||||||
|
entry: RegistryEntry;
|
||||||
|
onNavigate?: (entry: RegistryEntry) => void;
|
||||||
|
dimmed?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNavigate?.(entry)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded border px-2 py-1 text-left text-xs transition-colors hover:bg-accent w-full",
|
||||||
|
dimmed && "opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant={entry.direction === "intrat" ? "default" : "secondary"}
|
||||||
|
className="text-[9px] px-1 py-0 shrink-0"
|
||||||
|
>
|
||||||
|
{entry.direction === "intrat" ? "↓" : "↑"}
|
||||||
|
</Badge>
|
||||||
|
<span className="font-mono shrink-0">{entry.number}</span>
|
||||||
|
<span className="truncate text-muted-foreground">
|
||||||
|
{entry.subject.length > 40
|
||||||
|
? entry.subject.slice(0, 40) + "…"
|
||||||
|
: entry.subject}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto text-[10px] text-muted-foreground shrink-0">
|
||||||
|
{formatDate(entry.date)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString("ro-RO", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
export { registraturaConfig } from './config';
|
export { registraturaConfig } from "./config";
|
||||||
export { RegistraturaModule } from './components/registratura-module';
|
export { RegistraturaModule } from "./components/registratura-module";
|
||||||
export type {
|
export type {
|
||||||
RegistryEntry, RegistryDirection, RegistryStatus, DocumentType,
|
RegistryEntry,
|
||||||
DeadlineDayType, DeadlineResolution, DeadlineCategory,
|
RegistryDirection,
|
||||||
DeadlineTypeDef, TrackedDeadline,
|
RegistryStatus,
|
||||||
} from './types';
|
DocumentType,
|
||||||
|
DeadlineDayType,
|
||||||
|
DeadlineResolution,
|
||||||
|
DeadlineCategory,
|
||||||
|
DeadlineTypeDef,
|
||||||
|
TrackedDeadline,
|
||||||
|
} from "./types";
|
||||||
|
export { DEFAULT_DOCUMENT_TYPES, DEFAULT_DOC_TYPE_LABELS } from "./types";
|
||||||
|
|||||||
@@ -1,23 +1,44 @@
|
|||||||
import type { Visibility } from '@/core/module-registry/types';
|
import type { Visibility } from "@/core/module-registry/types";
|
||||||
import type { CompanyId } from '@/core/auth/types';
|
import type { CompanyId } from "@/core/auth/types";
|
||||||
|
|
||||||
/** Document direction — simplified from the old 3-way type */
|
/** Document direction — simplified from the old 3-way type */
|
||||||
export type RegistryDirection = 'intrat' | 'iesit';
|
export type RegistryDirection = "intrat" | "iesit";
|
||||||
|
|
||||||
/** Document type categories */
|
/** Default document types — user can add custom types that sync with Tag Manager */
|
||||||
export type DocumentType =
|
export const DEFAULT_DOCUMENT_TYPES = [
|
||||||
| 'contract'
|
"contract",
|
||||||
| 'oferta'
|
"oferta",
|
||||||
| 'factura'
|
"factura",
|
||||||
| 'scrisoare'
|
"scrisoare",
|
||||||
| 'aviz'
|
"aviz",
|
||||||
| 'nota-de-comanda'
|
"nota-de-comanda",
|
||||||
| 'raport'
|
"raport",
|
||||||
| 'cerere'
|
"cerere",
|
||||||
| 'altele';
|
"apel-telefonic",
|
||||||
|
"videoconferinta",
|
||||||
|
"altele",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Document type — string-based for dynamic types from Tag Manager */
|
||||||
|
export type DocumentType = (typeof DEFAULT_DOCUMENT_TYPES)[number] | string;
|
||||||
|
|
||||||
|
/** Labels for default document types */
|
||||||
|
export const DEFAULT_DOC_TYPE_LABELS: Record<string, string> = {
|
||||||
|
contract: "Contract",
|
||||||
|
oferta: "Ofertă",
|
||||||
|
factura: "Factură",
|
||||||
|
scrisoare: "Scrisoare",
|
||||||
|
aviz: "Aviz",
|
||||||
|
"nota-de-comanda": "Notă de comandă",
|
||||||
|
raport: "Raport",
|
||||||
|
cerere: "Cerere",
|
||||||
|
"apel-telefonic": "Apel telefonic",
|
||||||
|
videoconferinta: "Videoconferință",
|
||||||
|
altele: "Altele",
|
||||||
|
};
|
||||||
|
|
||||||
/** Status — simplified to open/closed */
|
/** Status — simplified to open/closed */
|
||||||
export type RegistryStatus = 'deschis' | 'inchis';
|
export type RegistryStatus = "deschis" | "inchis";
|
||||||
|
|
||||||
/** File attachment */
|
/** File attachment */
|
||||||
export interface RegistryAttachment {
|
export interface RegistryAttachment {
|
||||||
@@ -32,11 +53,21 @@ export interface RegistryAttachment {
|
|||||||
|
|
||||||
// ── Deadline tracking types ──
|
// ── Deadline tracking types ──
|
||||||
|
|
||||||
export type DeadlineDayType = 'calendar' | 'working';
|
export type DeadlineDayType = "calendar" | "working";
|
||||||
|
|
||||||
export type DeadlineResolution = 'pending' | 'completed' | 'aprobat-tacit' | 'respins' | 'anulat';
|
export type DeadlineResolution =
|
||||||
|
| "pending"
|
||||||
|
| "completed"
|
||||||
|
| "aprobat-tacit"
|
||||||
|
| "respins"
|
||||||
|
| "anulat";
|
||||||
|
|
||||||
export type DeadlineCategory = 'avize' | 'completari' | 'analiza' | 'autorizare' | 'publicitate';
|
export type DeadlineCategory =
|
||||||
|
| "avize"
|
||||||
|
| "completari"
|
||||||
|
| "analiza"
|
||||||
|
| "autorizare"
|
||||||
|
| "publicitate";
|
||||||
|
|
||||||
export interface DeadlineTypeDef {
|
export interface DeadlineTypeDef {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -86,6 +117,11 @@ export interface RegistryEntry {
|
|||||||
status: RegistryStatus;
|
status: RegistryStatus;
|
||||||
/** Deadline date (YYYY-MM-DD) */
|
/** Deadline date (YYYY-MM-DD) */
|
||||||
deadline?: string;
|
deadline?: string;
|
||||||
|
/** Assignee — person responsible (ERP-ready field) */
|
||||||
|
assignee?: string;
|
||||||
|
assigneeContactId?: string;
|
||||||
|
/** Thread parent — ID of the entry this is a reply to */
|
||||||
|
threadParentId?: string;
|
||||||
/** Linked entry IDs (for closing/archiving related entries) */
|
/** Linked entry IDs (for closing/archiving related entries) */
|
||||||
linkedEntryIds: string[];
|
linkedEntryIds: string[];
|
||||||
/** File attachments */
|
/** File attachments */
|
||||||
|
|||||||
Reference in New Issue
Block a user