feat(registratura): add legal deadline tracking system (Termene Legale)
Full deadline tracking engine for Romanian construction permitting: - 16 deadline types across 5 categories (Avize, Completări, Analiză, Autorizare, Publicitate) - Working days vs calendar days with Romanian public holidays (Orthodox Easter via Meeus) - Backward deadlines (AC extension: 45 working days BEFORE expiry) - Chain deadlines (resolving one prompts adding the next) - Tacit approval auto-detection (overdue + applicable type) - Tabbed UI: Registru + Termene legale dashboard with stats/filters/table - Inline deadline cards in entry form with add/resolve/remove - Clock icon + count badge on registry table for entries with deadlines Also adds CLAUDE.md with full project context for AI assistant handoff. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
266
CLAUDE.md
Normal file
266
CLAUDE.md
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
# ArchiTools — Project Context for AI Assistants
|
||||||
|
|
||||||
|
> This file provides all context needed for Claude Code, Sonnet, or any AI model to work on this project from scratch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:3000
|
||||||
|
npx next build # verify zero errors before pushing
|
||||||
|
git push origin main # auto-deploys via Portainer webhook
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**ArchiTools** is a modular internal web dashboard for an architecture/engineering office group of 3 companies:
|
||||||
|
- **Beletage** (architecture)
|
||||||
|
- **Urban Switch** (urbanism)
|
||||||
|
- **Studii de Teren** (geotechnics)
|
||||||
|
|
||||||
|
It runs on an on-premise Ubuntu server at `10.10.10.166`, containerized with Docker, managed via Portainer, served by Nginx Proxy Manager.
|
||||||
|
|
||||||
|
### Stack
|
||||||
|
| Layer | Technology |
|
||||||
|
|---|---|
|
||||||
|
| Framework | Next.js 16.x, App Router, TypeScript (strict) |
|
||||||
|
| Styling | Tailwind CSS v4, shadcn/ui |
|
||||||
|
| State | localStorage (via StorageService abstraction) |
|
||||||
|
| Deploy | Docker multi-stage, Portainer, Nginx Proxy Manager |
|
||||||
|
| Repo | Gitea at `http://10.10.10.166:3002/gitadmin/ArchiTools` |
|
||||||
|
| Language | Code in **English**, UI in **Romanian** |
|
||||||
|
|
||||||
|
### Architecture Principles
|
||||||
|
- **Module platform, not monolith** — each module isolated with own types/services/hooks/components
|
||||||
|
- **Feature flags** gate module loading (disabled = zero bundle cost)
|
||||||
|
- **Storage abstraction**: `StorageService` interface with adapters (localStorage default, designed for future DB/MinIO)
|
||||||
|
- **Cross-module tagging system** as shared service
|
||||||
|
- **Auth stub** designed for future Authentik SSO integration
|
||||||
|
- **All entities** include `visibility` / `createdBy` fields from day one
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # Routing only (thin wrappers)
|
||||||
|
│ ├── (modules)/ # Module route pages
|
||||||
|
│ └── layout.tsx # App shell
|
||||||
|
├── core/ # Platform services
|
||||||
|
│ ├── module-registry/ # Module registration + types
|
||||||
|
│ ├── feature-flags/ # Flag evaluation + env override
|
||||||
|
│ ├── storage/ # StorageService + adapters
|
||||||
|
│ │ └── adapters/ # localStorage adapter (+ future DB/MinIO)
|
||||||
|
│ ├── tagging/ # Cross-module tag service
|
||||||
|
│ ├── i18n/ # Romanian translations
|
||||||
|
│ ├── theme/ # Light/dark theme
|
||||||
|
│ └── auth/ # Auth types + stub (future Authentik)
|
||||||
|
├── modules/ # Module business logic
|
||||||
|
│ ├── <module-name>/
|
||||||
|
│ │ ├── components/ # Module UI components
|
||||||
|
│ │ ├── hooks/ # Module-specific hooks
|
||||||
|
│ │ ├── services/ # Module business logic
|
||||||
|
│ │ ├── types.ts # Module types
|
||||||
|
│ │ ├── config.ts # Module metadata
|
||||||
|
│ │ └── index.ts # Public exports
|
||||||
|
│ └── ...
|
||||||
|
├── shared/ # Shared UI
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── ui/ # shadcn/ui primitives
|
||||||
|
│ │ ├── layout/ # Sidebar, Header
|
||||||
|
│ │ └── common/ # Reusable app components
|
||||||
|
│ ├── hooks/ # Shared hooks
|
||||||
|
│ └── lib/ # Utils (cn, etc.)
|
||||||
|
├── config/ # Global config
|
||||||
|
│ ├── modules.ts # Module registry entries
|
||||||
|
│ ├── flags.ts # Default feature flags
|
||||||
|
│ ├── navigation.ts # Sidebar nav structure
|
||||||
|
│ └── companies.ts # Company definitions
|
||||||
|
docs/ # 16 internal technical docs
|
||||||
|
legacy/ # Original HTML tools for reference
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implemented Modules (13/13 — zero placeholders)
|
||||||
|
|
||||||
|
| # | Module | Route | Key Features |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Dashboard** | `/` | Stats cards, module grid, external tools by category |
|
||||||
|
| 2 | **Email Signature** | `/email-signature` | Multi-company branding, live preview, zoom/copy/download |
|
||||||
|
| 3 | **Word XML Generator** | `/word-xml` | Category-based XML gen, simple/advanced mode, ZIP export |
|
||||||
|
| 4 | **Registratura** | `/registratura` | CRUD registry, stats, filters, **legal deadline tracking** |
|
||||||
|
| 5 | **Tag Manager** | `/tag-manager` | CRUD tags, category/scope/color, grouped display |
|
||||||
|
| 6 | **IT Inventory** | `/it-inventory` | Equipment tracking, type/status/company filters |
|
||||||
|
| 7 | **Address Book** | `/address-book` | CRUD contacts, card grid, search/type filter |
|
||||||
|
| 8 | **Password Vault** | `/password-vault` | CRUD credentials, show/hide/copy, category filter |
|
||||||
|
| 9 | **Mini Utilities** | `/mini-utilities` | Text case, char counter, percentage calc, area converter |
|
||||||
|
| 10 | **Prompt Generator** | `/prompt-generator` | Template-driven prompt builder, 4 builtin templates |
|
||||||
|
| 11 | **Digital Signatures** | `/digital-signatures` | CRUD signature/stamp/initials assets |
|
||||||
|
| 12 | **Word Templates** | `/word-templates` | Template library, 8 categories, version tracking |
|
||||||
|
| 13 | **AI Chat** | `/ai-chat` | Session-based chat UI, demo mode (no API keys yet) |
|
||||||
|
|
||||||
|
### Registratura — Legal Deadline Tracking (Termene Legale)
|
||||||
|
|
||||||
|
The Registratura module includes a full legal deadline tracking engine for Romanian construction permitting:
|
||||||
|
- **16 deadline types** across 5 categories (Avize, Completări, Analiză, Autorizare, Publicitate)
|
||||||
|
- **Working days vs calendar days** with Romanian public holiday support (including Orthodox Easter via Meeus algorithm)
|
||||||
|
- **Backward deadlines** (e.g., AC extension: 45 working days BEFORE expiry)
|
||||||
|
- **Chain deadlines** (resolving one prompts adding the next)
|
||||||
|
- **Tacit approval** (auto-detected when overdue + applicable type)
|
||||||
|
- **Tabbed UI**: "Registru" tab (existing registry) + "Termene legale" tab (deadline dashboard)
|
||||||
|
|
||||||
|
Key files:
|
||||||
|
- `services/working-days.ts` — Romanian holidays, `addWorkingDays()`, `isWorkingDay()`
|
||||||
|
- `services/deadline-catalog.ts` — 16 `DeadlineTypeDef` entries
|
||||||
|
- `services/deadline-service.ts` — `createTrackedDeadline()`, `resolveDeadline()`, `aggregateDeadlines()`
|
||||||
|
- `components/deadline-dashboard.tsx` — Stats + filters + table
|
||||||
|
- `components/deadline-add-dialog.tsx` — 3-step wizard (category → type → date preview)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
### Server: `10.10.10.166` (Ubuntu)
|
||||||
|
|
||||||
|
| Service | Port | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| **ArchiTools** | 3000 | This app |
|
||||||
|
| **Gitea** | 3002 | Git hosting (`gitadmin/ArchiTools`) |
|
||||||
|
| **Portainer** | 9000 | Docker management, auto-deploy on push |
|
||||||
|
| **Nginx Proxy Manager** | 81 (admin) | Reverse proxy + SSL termination |
|
||||||
|
| **Uptime Kuma** | 3001 | Service monitoring |
|
||||||
|
| **MinIO** | 9003 | Object storage (future) |
|
||||||
|
| **N8N** | 5678 | Workflow automation (future) |
|
||||||
|
| **Stirling PDF** | 8087 | PDF tools |
|
||||||
|
| **IT-Tools** | 8085 | Developer utilities |
|
||||||
|
| **FileBrowser** | 8086 | File management |
|
||||||
|
| **Netdata** | 19999 | System monitoring |
|
||||||
|
| **Dozzle** | 9999 | Docker log viewer |
|
||||||
|
| **CrowdSec** | 8088 | Security |
|
||||||
|
| **Authentik** | 9100 | SSO (future) |
|
||||||
|
|
||||||
|
### Deployment Pipeline
|
||||||
|
|
||||||
|
```
|
||||||
|
git push origin main
|
||||||
|
→ Gitea webhook fires
|
||||||
|
→ Portainer auto-redeploys stack
|
||||||
|
→ Docker multi-stage build (~1-2 min)
|
||||||
|
→ Container starts on :3000
|
||||||
|
→ Nginx Proxy Manager routes traffic
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
- `Dockerfile`: 3-stage build (deps → builder → runner), `node:20-alpine`, non-root user
|
||||||
|
- `docker-compose.yml`: single service, port 3000, watchtower label
|
||||||
|
- `output: 'standalone'` in `next.config.ts` is **required**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Rules
|
||||||
|
|
||||||
|
### TypeScript Strict Mode Gotchas
|
||||||
|
- `array.split()[0]` returns `string | undefined` — use `.slice(0, 10)` instead
|
||||||
|
- `Record<string, T>[key]` returns `T | undefined` — always guard with null check
|
||||||
|
- Spread of possibly-undefined objects: `{ ...obj[key], field }` — check existence first
|
||||||
|
- lucide-react Icons: cast through `unknown` → `React.ComponentType<{ className?: string }>`
|
||||||
|
|
||||||
|
### Conventions
|
||||||
|
- **Code**: English
|
||||||
|
- **UI text**: Romanian
|
||||||
|
- **Components**: functional, `'use client'` directive where needed
|
||||||
|
- **State**: localStorage via `useStorage('module-name')` hook
|
||||||
|
- **IDs**: `uuid v4`
|
||||||
|
- **Dates**: ISO strings (`YYYY-MM-DD` for display, full ISO for timestamps)
|
||||||
|
- **No emojis** in code or UI unless explicitly requested
|
||||||
|
|
||||||
|
### Module Development Pattern
|
||||||
|
Every module follows:
|
||||||
|
```
|
||||||
|
src/modules/<name>/
|
||||||
|
├── components/ # React components
|
||||||
|
├── hooks/ # Custom hooks (use-<name>.ts)
|
||||||
|
├── services/ # Business logic (pure functions)
|
||||||
|
├── types.ts # TypeScript interfaces
|
||||||
|
├── config.ts # ModuleConfig metadata
|
||||||
|
└── index.ts # Public exports
|
||||||
|
```
|
||||||
|
|
||||||
|
### Before Pushing
|
||||||
|
1. `npx next build` — must pass with zero errors
|
||||||
|
2. Test the feature manually on `localhost:3000`
|
||||||
|
3. Commit with descriptive message
|
||||||
|
4. `git push origin main` — Portainer auto-deploys
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Company IDs
|
||||||
|
|
||||||
|
| ID | Name | Prefix |
|
||||||
|
|---|---|---|
|
||||||
|
| `beletage` | Beletage | B |
|
||||||
|
| `urban-switch` | Urban Switch | US |
|
||||||
|
| `studii-de-teren` | Studii de Teren | SDT |
|
||||||
|
| `group` | Grup | G |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Integrations (not yet implemented)
|
||||||
|
|
||||||
|
| Feature | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| **Authentik SSO** | Auth stub exists | `src/core/auth/` has types + provider shell |
|
||||||
|
| **MinIO storage** | Adapter pattern ready | Switch `NEXT_PUBLIC_STORAGE_ADAPTER` to `minio` |
|
||||||
|
| **API backend** | Adapter pattern ready | Switch to `api` adapter when backend exists |
|
||||||
|
| **AI Chat API** | UI complete, demo mode | No API keys yet; supports Claude/GPT/Ollama |
|
||||||
|
| **N8N automations** | Webhook URL configured | For notifications, backups, workflows |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Model Recommendations for Tasks
|
||||||
|
|
||||||
|
| Task Type | Recommended Model | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| **Bug fixes, small edits** | Sonnet 4.5 or Haiku 4.5 | Fast, cheap, good for focused changes |
|
||||||
|
| **New module implementation** | Opus 4.6 | Complex architecture decisions, multi-file coordination |
|
||||||
|
| **Refactoring** | Sonnet 4.5 | Good pattern recognition, thorough but faster than Opus |
|
||||||
|
| **UI polish / styling** | Sonnet 4.5 | Tailwind expertise, fast iteration |
|
||||||
|
| **Documentation** | Sonnet 4.5 | Clear writing, fast output |
|
||||||
|
| **Complex business logic** | Opus 4.6 | Legal deadline logic, calendar math, chain workflows |
|
||||||
|
| **Build/deploy debugging** | Haiku 4.5 | Quick diagnosis, low cost |
|
||||||
|
|
||||||
|
### Session Handoff Tips
|
||||||
|
- Read this `CLAUDE.md` first — it has all context
|
||||||
|
- Check `docs/` for deep dives on specific systems
|
||||||
|
- Check `src/modules/<name>/types.ts` before modifying any module
|
||||||
|
- Always run `npx next build` before committing
|
||||||
|
- The 16 docs in `docs/` total ~10,600 lines — search them for architecture questions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation Index
|
||||||
|
|
||||||
|
| Doc | Path | Content |
|
||||||
|
|---|---|---|
|
||||||
|
| System Architecture | `docs/architecture/SYSTEM-ARCHITECTURE.md` | Overall architecture, module platform design |
|
||||||
|
| Module System | `docs/architecture/MODULE-SYSTEM.md` | Module registry, lifecycle, config format |
|
||||||
|
| Feature Flags | `docs/architecture/FEATURE-FLAGS.md` | Flag system, env overrides |
|
||||||
|
| Storage Layer | `docs/architecture/STORAGE-LAYER.md` | StorageService interface, adapters |
|
||||||
|
| Tagging System | `docs/architecture/TAGGING-SYSTEM.md` | Cross-module tags |
|
||||||
|
| Security & Roles | `docs/architecture/SECURITY-AND-ROLES.md` | Visibility, auth, roles |
|
||||||
|
| Module Dev Guide | `docs/guides/MODULE-DEVELOPMENT.md` | How to create a new module |
|
||||||
|
| HTML Integration | `docs/guides/HTML-TOOL-INTEGRATION.md` | Legacy tool migration |
|
||||||
|
| UI Design System | `docs/guides/UI-DESIGN-SYSTEM.md` | Design tokens, component patterns |
|
||||||
|
| Docker Deployment | `docs/guides/DOCKER-DEPLOYMENT.md` | Full Docker/Portainer/Nginx guide |
|
||||||
|
| Coding Standards | `docs/guides/CODING-STANDARDS.md` | TS strict, naming, patterns |
|
||||||
|
| Testing Strategy | `docs/guides/TESTING-STRATEGY.md` | Testing approach |
|
||||||
|
| Configuration | `docs/guides/CONFIGURATION.md` | Env vars, flags, companies |
|
||||||
|
| Data Model | `docs/DATA-MODEL.md` | All entity schemas |
|
||||||
|
| Repo Structure | `docs/REPO-STRUCTURE.md` | Directory layout |
|
||||||
|
| Prompt Generator | `docs/modules/PROMPT-GENERATOR.md` | Prompt module deep dive |
|
||||||
187
src/modules/registratura/components/deadline-add-dialog.tsx
Normal file
187
src/modules/registratura/components/deadline-add-dialog.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||||
|
} from '@/shared/components/ui/dialog';
|
||||||
|
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 { DEADLINE_CATALOG, CATEGORY_LABELS } from '../services/deadline-catalog';
|
||||||
|
import { computeDueDate } from '../services/working-days';
|
||||||
|
import type { DeadlineCategory, DeadlineTypeDef } from '../types';
|
||||||
|
|
||||||
|
interface DeadlineAddDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
entryDate: string;
|
||||||
|
onAdd: (typeId: string, startDate: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Step = 'category' | 'type' | 'date';
|
||||||
|
|
||||||
|
const CATEGORIES: DeadlineCategory[] = ['avize', 'completari', 'analiza', 'autorizare', 'publicitate'];
|
||||||
|
|
||||||
|
export function DeadlineAddDialog({ open, onOpenChange, entryDate, onAdd }: DeadlineAddDialogProps) {
|
||||||
|
const [step, setStep] = useState<Step>('category');
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<DeadlineCategory | null>(null);
|
||||||
|
const [selectedType, setSelectedType] = useState<DeadlineTypeDef | null>(null);
|
||||||
|
const [startDate, setStartDate] = useState(entryDate);
|
||||||
|
|
||||||
|
const typesForCategory = useMemo(() => {
|
||||||
|
if (!selectedCategory) return [];
|
||||||
|
return DEADLINE_CATALOG.filter((d) => d.category === selectedCategory);
|
||||||
|
}, [selectedCategory]);
|
||||||
|
|
||||||
|
const dueDatePreview = useMemo(() => {
|
||||||
|
if (!selectedType || !startDate) return null;
|
||||||
|
const start = new Date(startDate);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
const due = computeDueDate(start, selectedType.days, selectedType.dayType, selectedType.isBackwardDeadline);
|
||||||
|
return due.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
}, [selectedType, startDate]);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setStep('category');
|
||||||
|
setSelectedCategory(null);
|
||||||
|
setSelectedType(null);
|
||||||
|
setStartDate(entryDate);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCategorySelect = (cat: DeadlineCategory) => {
|
||||||
|
setSelectedCategory(cat);
|
||||||
|
setStep('type');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTypeSelect = (typ: DeadlineTypeDef) => {
|
||||||
|
setSelectedType(typ);
|
||||||
|
if (!typ.requiresCustomStartDate) {
|
||||||
|
setStartDate(entryDate);
|
||||||
|
}
|
||||||
|
setStep('date');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (step === 'type') {
|
||||||
|
setStep('category');
|
||||||
|
setSelectedCategory(null);
|
||||||
|
} else if (step === 'date') {
|
||||||
|
setStep('type');
|
||||||
|
setSelectedType(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (!selectedType || !startDate) return;
|
||||||
|
onAdd(selectedType.id, startDate);
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => { if (!o) handleClose(); }}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{step === 'category' && 'Adaugă termen legal — Categorie'}
|
||||||
|
{step === 'type' && `Adaugă termen legal — ${selectedCategory ? CATEGORY_LABELS[selectedCategory] : ''}`}
|
||||||
|
{step === 'date' && `Adaugă termen legal — ${selectedType?.label ?? ''}`}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{step === 'category' && (
|
||||||
|
<div className="grid gap-2 py-2">
|
||||||
|
{CATEGORIES.map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
type="button"
|
||||||
|
className="flex items-center justify-between rounded-lg border p-3 text-left transition-colors hover:bg-accent"
|
||||||
|
onClick={() => handleCategorySelect(cat)}
|
||||||
|
>
|
||||||
|
<span className="font-medium text-sm">{CATEGORY_LABELS[cat]}</span>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{DEADLINE_CATALOG.filter((d) => d.category === cat).length}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'type' && (
|
||||||
|
<div className="grid gap-2 py-2">
|
||||||
|
{typesForCategory.map((typ) => (
|
||||||
|
<button
|
||||||
|
key={typ.id}
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg border p-3 text-left transition-colors hover:bg-accent"
|
||||||
|
onClick={() => handleTypeSelect(typ)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm">{typ.label}</span>
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{typ.days} {typ.dayType === 'working' ? 'zile lucr.' : 'zile cal.'}
|
||||||
|
</Badge>
|
||||||
|
{typ.tacitApprovalApplicable && (
|
||||||
|
<Badge variant="outline" className="text-[10px] text-blue-600">tacit</Badge>
|
||||||
|
)}
|
||||||
|
{typ.isBackwardDeadline && (
|
||||||
|
<Badge variant="outline" className="text-[10px] text-orange-600">înapoi</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{typ.description}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'date' && selectedType && (
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div>
|
||||||
|
<Label>{selectedType.startDateLabel}</Label>
|
||||||
|
{selectedType.startDateHint && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{selectedType.startDateHint}</p>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dueDatePreview && (
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{selectedType.isBackwardDeadline ? 'Termen limită depunere' : 'Termen limită calculat'}
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold">{dueDatePreview}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{selectedType.days} {selectedType.dayType === 'working' ? 'zile lucrătoare' : 'zile calendaristice'}
|
||||||
|
{selectedType.isBackwardDeadline ? ' ÎNAINTE' : ' de la data start'}
|
||||||
|
</p>
|
||||||
|
{selectedType.legalReference && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 italic">Ref: {selectedType.legalReference}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
{step !== 'category' && (
|
||||||
|
<Button type="button" variant="outline" onClick={handleBack}>Înapoi</Button>
|
||||||
|
)}
|
||||||
|
{step === 'category' && (
|
||||||
|
<Button type="button" variant="outline" onClick={handleClose}>Anulează</Button>
|
||||||
|
)}
|
||||||
|
{step === 'date' && (
|
||||||
|
<Button type="button" onClick={handleConfirm} disabled={!startDate}>
|
||||||
|
Adaugă termen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/modules/registratura/components/deadline-card.tsx
Normal file
93
src/modules/registratura/components/deadline-card.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Clock, CheckCircle2, X } from 'lucide-react';
|
||||||
|
import { Badge } from '@/shared/components/ui/badge';
|
||||||
|
import { Button } from '@/shared/components/ui/button';
|
||||||
|
import type { TrackedDeadline } from '../types';
|
||||||
|
import { getDeadlineType } from '../services/deadline-catalog';
|
||||||
|
import { getDeadlineDisplayStatus } from '../services/deadline-service';
|
||||||
|
import { cn } from '@/shared/lib/utils';
|
||||||
|
|
||||||
|
interface DeadlineCardProps {
|
||||||
|
deadline: TrackedDeadline;
|
||||||
|
onResolve: (deadline: TrackedDeadline) => void;
|
||||||
|
onRemove: (deadlineId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANT_CLASSES: Record<string, string> = {
|
||||||
|
green: 'border-green-500/30 bg-green-50 dark:bg-green-950/20',
|
||||||
|
yellow: 'border-yellow-500/30 bg-yellow-50 dark:bg-yellow-950/20',
|
||||||
|
red: 'border-red-500/30 bg-red-50 dark:bg-red-950/20',
|
||||||
|
blue: 'border-blue-500/30 bg-blue-50 dark:bg-blue-950/20',
|
||||||
|
gray: 'border-muted bg-muted/30',
|
||||||
|
};
|
||||||
|
|
||||||
|
const BADGE_CLASSES: Record<string, string> = {
|
||||||
|
green: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||||
|
yellow: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||||
|
red: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||||
|
blue: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||||
|
gray: 'bg-muted text-muted-foreground',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DeadlineCard({ deadline, onResolve, onRemove }: DeadlineCardProps) {
|
||||||
|
const def = getDeadlineType(deadline.typeId);
|
||||||
|
const status = getDeadlineDisplayStatus(deadline);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-3 rounded-lg border p-3', VARIANT_CLASSES[status.variant] ?? '')}>
|
||||||
|
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium truncate">{def?.label ?? deadline.typeId}</span>
|
||||||
|
<Badge className={cn('text-[10px] border-0', BADGE_CLASSES[status.variant] ?? '')}>
|
||||||
|
{status.label}
|
||||||
|
{status.daysRemaining !== null && status.variant !== 'blue' && (
|
||||||
|
<span className="ml-1">
|
||||||
|
({status.daysRemaining < 0 ? `${Math.abs(status.daysRemaining)}z depășit` : `${status.daysRemaining}z`})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{def?.isBackwardDeadline ? 'Termen limită' : 'Start'}: {formatDate(deadline.startDate)}
|
||||||
|
{' → '}
|
||||||
|
{def?.isBackwardDeadline ? 'Depunere până la' : 'Termen'}: {formatDate(deadline.dueDate)}
|
||||||
|
{def?.dayType === 'working' && <span className="ml-1">(zile lucrătoare)</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
{deadline.resolution === 'pending' && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-green-600"
|
||||||
|
onClick={() => onResolve(deadline)}
|
||||||
|
title="Rezolvă"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
onClick={() => onRemove(deadline.id)}
|
||||||
|
title="Șterge"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
160
src/modules/registratura/components/deadline-dashboard.tsx
Normal file
160
src/modules/registratura/components/deadline-dashboard.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Card, CardContent } from '@/shared/components/ui/card';
|
||||||
|
import { Badge } from '@/shared/components/ui/badge';
|
||||||
|
import { Label } from '@/shared/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
||||||
|
import { Button } from '@/shared/components/ui/button';
|
||||||
|
import type { RegistryEntry, TrackedDeadline, DeadlineResolution, DeadlineCategory } from '../types';
|
||||||
|
import { aggregateDeadlines } from '../services/deadline-service';
|
||||||
|
import { CATEGORY_LABELS, getDeadlineType } from '../services/deadline-catalog';
|
||||||
|
import { useDeadlineFilters } from '../hooks/use-deadline-filters';
|
||||||
|
import { DeadlineTable } from './deadline-table';
|
||||||
|
import { DeadlineResolveDialog } from './deadline-resolve-dialog';
|
||||||
|
|
||||||
|
interface DeadlineDashboardProps {
|
||||||
|
entries: RegistryEntry[];
|
||||||
|
onResolveDeadline: (entryId: string, deadlineId: string, resolution: DeadlineResolution, note: string, chainNext: boolean) => void;
|
||||||
|
onAddChainedDeadline: (entryId: string, typeId: string, startDate: string, parentId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESOLUTION_LABELS: Record<string, string> = {
|
||||||
|
pending: 'În așteptare',
|
||||||
|
completed: 'Finalizat',
|
||||||
|
'aprobat-tacit': 'Aprobat tacit',
|
||||||
|
respins: 'Respins',
|
||||||
|
anulat: 'Anulat',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DeadlineDashboard({ entries, onResolveDeadline, onAddChainedDeadline }: DeadlineDashboardProps) {
|
||||||
|
const { filters, updateFilter } = useDeadlineFilters();
|
||||||
|
const [resolvingEntry, setResolvingEntry] = useState<string | null>(null);
|
||||||
|
const [resolvingDeadline, setResolvingDeadline] = useState<TrackedDeadline | null>(null);
|
||||||
|
|
||||||
|
const stats = useMemo(() => aggregateDeadlines(entries), [entries]);
|
||||||
|
|
||||||
|
const filteredRows = useMemo(() => {
|
||||||
|
return stats.all.filter((row) => {
|
||||||
|
if (filters.category !== 'all') {
|
||||||
|
const def = getDeadlineType(row.deadline.typeId);
|
||||||
|
if (def && def.category !== filters.category) return false;
|
||||||
|
}
|
||||||
|
if (filters.resolution !== 'all') {
|
||||||
|
// Map tacit display status to actual resolution filter
|
||||||
|
if (filters.resolution === 'pending') {
|
||||||
|
if (row.deadline.resolution !== 'pending') return false;
|
||||||
|
} else if (row.deadline.resolution !== filters.resolution) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filters.urgentOnly) {
|
||||||
|
if (row.status.variant !== 'yellow' && row.status.variant !== 'red') return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [stats.all, filters]);
|
||||||
|
|
||||||
|
const handleResolveClick = (entryId: string, deadline: TrackedDeadline) => {
|
||||||
|
setResolvingEntry(entryId);
|
||||||
|
setResolvingDeadline(deadline);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResolve = (resolution: DeadlineResolution, note: string, chainNext: boolean) => {
|
||||||
|
if (!resolvingEntry || !resolvingDeadline) return;
|
||||||
|
onResolveDeadline(resolvingEntry, resolvingDeadline.id, resolution, note, chainNext);
|
||||||
|
|
||||||
|
// Handle chain creation
|
||||||
|
if (chainNext) {
|
||||||
|
const def = getDeadlineType(resolvingDeadline.typeId);
|
||||||
|
if (def?.chainNextTypeId) {
|
||||||
|
const resolvedDate = new Date().toISOString().slice(0, 10);
|
||||||
|
onAddChainedDeadline(resolvingEntry, def.chainNextTypeId, resolvedDate, resolvingDeadline.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setResolvingEntry(null);
|
||||||
|
setResolvingDeadline(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<StatCard label="Active" value={stats.active} />
|
||||||
|
<StatCard label="Urgente" value={stats.urgent} variant={stats.urgent > 0 ? 'destructive' : undefined} />
|
||||||
|
<StatCard label="Depășit termen" value={stats.overdue} variant={stats.overdue > 0 ? 'destructive' : undefined} />
|
||||||
|
<StatCard label="Aprobat tacit" value={stats.tacit} variant={stats.tacit > 0 ? 'blue' : undefined} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Categorie</Label>
|
||||||
|
<Select value={filters.category} onValueChange={(v) => updateFilter('category', v as DeadlineCategory | 'all')}>
|
||||||
|
<SelectTrigger className="mt-1 w-[160px]"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Toate</SelectItem>
|
||||||
|
{(Object.entries(CATEGORY_LABELS) as [DeadlineCategory, string][]).map(([key, label]) => (
|
||||||
|
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Status</Label>
|
||||||
|
<Select value={filters.resolution} onValueChange={(v) => updateFilter('resolution', v as DeadlineResolution | 'all')}>
|
||||||
|
<SelectTrigger className="mt-1 w-[160px]"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Toate</SelectItem>
|
||||||
|
{Object.entries(RESOLUTION_LABELS).map(([key, label]) => (
|
||||||
|
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant={filters.urgentOnly ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateFilter('urgentOnly', !filters.urgentOnly)}
|
||||||
|
>
|
||||||
|
Doar urgente
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<DeadlineTable rows={filteredRows} onResolve={handleResolveClick} />
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{filteredRows.length} din {stats.all.length} termene afișate
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<DeadlineResolveDialog
|
||||||
|
open={resolvingDeadline !== null}
|
||||||
|
deadline={resolvingDeadline}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setResolvingEntry(null);
|
||||||
|
setResolvingDeadline(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onResolve={handleResolve}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, variant }: { label: string; value: number; variant?: 'destructive' | 'blue' }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className={`text-2xl font-bold ${
|
||||||
|
variant === 'destructive' && value > 0 ? 'text-destructive' : ''
|
||||||
|
}${variant === 'blue' && value > 0 ? 'text-blue-600' : ''}`}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/modules/registratura/components/deadline-resolve-dialog.tsx
Normal file
100
src/modules/registratura/components/deadline-resolve-dialog.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||||
|
} from '@/shared/components/ui/dialog';
|
||||||
|
import { Button } from '@/shared/components/ui/button';
|
||||||
|
import { Label } from '@/shared/components/ui/label';
|
||||||
|
import { Textarea } from '@/shared/components/ui/textarea';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
||||||
|
import type { TrackedDeadline, DeadlineResolution } from '../types';
|
||||||
|
import { getDeadlineType } from '../services/deadline-catalog';
|
||||||
|
|
||||||
|
interface DeadlineResolveDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
deadline: TrackedDeadline | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onResolve: (resolution: DeadlineResolution, note: string, chainNext: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESOLUTION_OPTIONS: Array<{ value: DeadlineResolution; label: string }> = [
|
||||||
|
{ value: 'completed', label: 'Finalizat' },
|
||||||
|
{ value: 'aprobat-tacit', label: 'Aprobat tacit' },
|
||||||
|
{ value: 'respins', label: 'Respins' },
|
||||||
|
{ value: 'anulat', label: 'Anulat' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DeadlineResolveDialog({ open, deadline, onOpenChange, onResolve }: DeadlineResolveDialogProps) {
|
||||||
|
const [resolution, setResolution] = useState<DeadlineResolution>('completed');
|
||||||
|
const [note, setNote] = useState('');
|
||||||
|
|
||||||
|
if (!deadline) return null;
|
||||||
|
|
||||||
|
const def = getDeadlineType(deadline.typeId);
|
||||||
|
const hasChain = def?.chainNextTypeId && (resolution === 'completed' || resolution === 'aprobat-tacit');
|
||||||
|
|
||||||
|
const handleResolve = () => {
|
||||||
|
onResolve(resolution, note, !!hasChain);
|
||||||
|
setResolution('completed');
|
||||||
|
setNote('');
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setResolution('completed');
|
||||||
|
setNote('');
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => { if (!o) handleClose(); }}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Rezolvă — {def?.label ?? deadline.typeId}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div>
|
||||||
|
<Label>Rezoluție</Label>
|
||||||
|
<Select value={resolution} onValueChange={(v) => setResolution(v as DeadlineResolution)}>
|
||||||
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{RESOLUTION_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Notă (opțional)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="Detalii rezoluție..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasChain && def?.chainNextActionLabel && (
|
||||||
|
<div className="rounded-lg border border-blue-500/30 bg-blue-50 dark:bg-blue-950/20 p-3">
|
||||||
|
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">
|
||||||
|
Termen înlănțuit disponibil
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-blue-700 dark:text-blue-300 mt-1">
|
||||||
|
La rezolvare veți fi întrebat dacă doriți: {def.chainNextActionLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={handleClose}>Anulează</Button>
|
||||||
|
<Button type="button" onClick={handleResolve}>Rezolvă</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/modules/registratura/components/deadline-table.tsx
Normal file
127
src/modules/registratura/components/deadline-table.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { CheckCircle2 } from 'lucide-react';
|
||||||
|
import { Badge } from '@/shared/components/ui/badge';
|
||||||
|
import { Button } from '@/shared/components/ui/button';
|
||||||
|
import type { TrackedDeadline, RegistryEntry } from '../types';
|
||||||
|
import type { DeadlineDisplayStatus } from '../services/deadline-service';
|
||||||
|
import { getDeadlineType, CATEGORY_LABELS } from '../services/deadline-catalog';
|
||||||
|
import { cn } from '@/shared/lib/utils';
|
||||||
|
|
||||||
|
interface DeadlineRow {
|
||||||
|
deadline: TrackedDeadline;
|
||||||
|
entry: RegistryEntry;
|
||||||
|
status: DeadlineDisplayStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeadlineTableProps {
|
||||||
|
rows: DeadlineRow[];
|
||||||
|
onResolve: (entryId: string, deadline: TrackedDeadline) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BADGE_CLASSES: Record<string, string> = {
|
||||||
|
green: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 border-0',
|
||||||
|
yellow: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 border-0',
|
||||||
|
red: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 border-0',
|
||||||
|
blue: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 border-0',
|
||||||
|
gray: 'bg-muted text-muted-foreground border-0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROW_CLASSES: Record<string, string> = {
|
||||||
|
red: 'bg-destructive/5',
|
||||||
|
yellow: 'bg-yellow-50/50 dark:bg-yellow-950/10',
|
||||||
|
blue: 'bg-blue-50/50 dark:bg-blue-950/10',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DeadlineTable({ rows, onResolve }: DeadlineTableProps) {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Niciun termen legal urmărit.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">Nr. înreg.</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Categorie</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Tip termen</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Data start</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Termen limită</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Zile</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>
|
||||||
|
{rows.map((row) => {
|
||||||
|
const def = getDeadlineType(row.deadline.typeId);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={row.deadline.id}
|
||||||
|
className={cn(
|
||||||
|
'border-b transition-colors hover:bg-muted/20',
|
||||||
|
ROW_CLASSES[row.status.variant] ?? '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">{row.entry.number}</td>
|
||||||
|
<td className="px-3 py-2 text-xs">
|
||||||
|
{def ? CATEGORY_LABELS[def.category] : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-xs">
|
||||||
|
<span className="font-medium">{def?.label ?? row.deadline.typeId}</span>
|
||||||
|
{def?.dayType === 'working' && (
|
||||||
|
<span className="ml-1 text-muted-foreground">(lucr.)</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-xs whitespace-nowrap">{formatDate(row.deadline.startDate)}</td>
|
||||||
|
<td className="px-3 py-2 text-xs whitespace-nowrap font-medium">{formatDate(row.deadline.dueDate)}</td>
|
||||||
|
<td className="px-3 py-2 text-xs whitespace-nowrap">
|
||||||
|
{row.status.daysRemaining !== null ? (
|
||||||
|
<span className={cn(row.status.daysRemaining < 0 && 'text-destructive font-medium')}>
|
||||||
|
{row.status.daysRemaining < 0
|
||||||
|
? `${Math.abs(row.status.daysRemaining)}z depășit`
|
||||||
|
: `${row.status.daysRemaining}z`}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Badge className={cn('text-[10px]', BADGE_CLASSES[row.status.variant] ?? '')}>
|
||||||
|
{row.status.label}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
{row.deadline.resolution === 'pending' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-green-600"
|
||||||
|
onClick={() => onResolve(row.entry.id, row.deadline)}
|
||||||
|
title="Rezolvă"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,10 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useMemo } 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 { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
||||||
import { Badge } from '@/shared/components/ui/badge';
|
import { Badge } from '@/shared/components/ui/badge';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/components/ui/tabs';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||||
} from '@/shared/components/ui/dialog';
|
} from '@/shared/components/ui/dialog';
|
||||||
@@ -12,8 +13,10 @@ import { useRegistry } from '../hooks/use-registry';
|
|||||||
import { RegistryFilters } from './registry-filters';
|
import { RegistryFilters } from './registry-filters';
|
||||||
import { RegistryTable } from './registry-table';
|
import { RegistryTable } from './registry-table';
|
||||||
import { RegistryEntryForm } from './registry-entry-form';
|
import { RegistryEntryForm } from './registry-entry-form';
|
||||||
|
import { DeadlineDashboard } from './deadline-dashboard';
|
||||||
import { getOverdueDays } from '../services/registry-service';
|
import { getOverdueDays } from '../services/registry-service';
|
||||||
import type { RegistryEntry } from '../types';
|
import { aggregateDeadlines } from '../services/deadline-service';
|
||||||
|
import type { RegistryEntry, DeadlineResolution } from '../types';
|
||||||
|
|
||||||
type ViewMode = 'list' | 'add' | 'edit';
|
type ViewMode = 'list' | 'add' | 'edit';
|
||||||
|
|
||||||
@@ -21,6 +24,7 @@ export function RegistraturaModule() {
|
|||||||
const {
|
const {
|
||||||
entries, allEntries, loading, filters, updateFilter,
|
entries, allEntries, loading, filters, updateFilter,
|
||||||
addEntry, updateEntry, removeEntry, closeEntry,
|
addEntry, updateEntry, removeEntry, closeEntry,
|
||||||
|
addDeadline, resolveDeadline, removeDeadline,
|
||||||
} = useRegistry();
|
} = useRegistry();
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||||
@@ -69,6 +73,21 @@ export function RegistraturaModule() {
|
|||||||
setEditingEntry(null);
|
setEditingEntry(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Dashboard deadline resolve/chain handlers ──
|
||||||
|
const handleDashboardResolve = async (
|
||||||
|
entryId: string,
|
||||||
|
deadlineId: string,
|
||||||
|
resolution: DeadlineResolution,
|
||||||
|
note: string,
|
||||||
|
_chainNext: boolean,
|
||||||
|
) => {
|
||||||
|
await resolveDeadline(entryId, deadlineId, resolution, note);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddChainedDeadline = async (entryId: string, typeId: string, startDate: string, parentId: string) => {
|
||||||
|
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;
|
||||||
@@ -79,9 +98,26 @@ export function RegistraturaModule() {
|
|||||||
}).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 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">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="registru">Registru</TabsTrigger>
|
||||||
|
<TabsTrigger value="termene">
|
||||||
|
Termene legale
|
||||||
|
{urgentDeadlines > 0 && (
|
||||||
|
<Badge variant="destructive" className="ml-1.5 text-[10px] px-1.5 py-0">
|
||||||
|
{urgentDeadlines}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="registru">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
@@ -174,6 +210,16 @@ export function RegistraturaModule() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="termene">
|
||||||
|
<DeadlineDashboard
|
||||||
|
entries={allEntries}
|
||||||
|
onResolveDeadline={handleDashboardResolve}
|
||||||
|
onAddChainedDeadline={handleAddChainedDeadline}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useMemo, useRef } from 'react';
|
import { useState, useMemo, useRef } from 'react';
|
||||||
import { Paperclip, X } from 'lucide-react';
|
import { Paperclip, X, Clock, Plus } from 'lucide-react';
|
||||||
import type { CompanyId } from '@/core/auth/types';
|
import type { CompanyId } from '@/core/auth/types';
|
||||||
import type { RegistryEntry, RegistryDirection, RegistryStatus, DocumentType, RegistryAttachment } from '../types';
|
import type { RegistryEntry, RegistryDirection, RegistryStatus, DocumentType, RegistryAttachment, TrackedDeadline, DeadlineResolution } 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';
|
||||||
@@ -12,6 +12,11 @@ import { Badge } from '@/shared/components/ui/badge';
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
||||||
import { useContacts } from '@/modules/address-book/hooks/use-contacts';
|
import { useContacts } from '@/modules/address-book/hooks/use-contacts';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { DeadlineCard } from './deadline-card';
|
||||||
|
import { DeadlineAddDialog } from './deadline-add-dialog';
|
||||||
|
import { DeadlineResolveDialog } from './deadline-resolve-dialog';
|
||||||
|
import { createTrackedDeadline, resolveDeadline as resolveDeadlineFn } from '../services/deadline-service';
|
||||||
|
import { getDeadlineType } from '../services/deadline-catalog';
|
||||||
|
|
||||||
interface RegistryEntryFormProps {
|
interface RegistryEntryFormProps {
|
||||||
initial?: RegistryEntry;
|
initial?: RegistryEntry;
|
||||||
@@ -50,6 +55,39 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
|
|||||||
const [notes, setNotes] = useState(initial?.notes ?? '');
|
const [notes, setNotes] = useState(initial?.notes ?? '');
|
||||||
const [linkedEntryIds, setLinkedEntryIds] = useState<string[]>(initial?.linkedEntryIds ?? []);
|
const [linkedEntryIds, setLinkedEntryIds] = useState<string[]>(initial?.linkedEntryIds ?? []);
|
||||||
const [attachments, setAttachments] = useState<RegistryAttachment[]>(initial?.attachments ?? []);
|
const [attachments, setAttachments] = useState<RegistryAttachment[]>(initial?.attachments ?? []);
|
||||||
|
const [trackedDeadlines, setTrackedDeadlines] = useState<TrackedDeadline[]>(initial?.trackedDeadlines ?? []);
|
||||||
|
|
||||||
|
// ── Deadline dialogs ──
|
||||||
|
const [deadlineAddOpen, setDeadlineAddOpen] = useState(false);
|
||||||
|
const [resolvingDeadline, setResolvingDeadline] = useState<TrackedDeadline | null>(null);
|
||||||
|
|
||||||
|
const handleAddDeadline = (typeId: string, startDate: string, chainParentId?: string) => {
|
||||||
|
const tracked = createTrackedDeadline(typeId, startDate, chainParentId);
|
||||||
|
if (tracked) setTrackedDeadlines((prev) => [...prev, tracked]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResolveDeadline = (resolution: DeadlineResolution, note: string, chainNext: boolean) => {
|
||||||
|
if (!resolvingDeadline) return;
|
||||||
|
const resolved = resolveDeadlineFn(resolvingDeadline, resolution, note);
|
||||||
|
setTrackedDeadlines((prev) =>
|
||||||
|
prev.map((d) => (d.id === resolved.id ? resolved : d))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle chain
|
||||||
|
if (chainNext) {
|
||||||
|
const def = getDeadlineType(resolvingDeadline.typeId);
|
||||||
|
if (def?.chainNextTypeId) {
|
||||||
|
const resolvedDate = new Date().toISOString().slice(0, 10);
|
||||||
|
const chained = createTrackedDeadline(def.chainNextTypeId, resolvedDate, resolvingDeadline.id);
|
||||||
|
if (chained) setTrackedDeadlines((prev) => [...prev, chained]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setResolvingDeadline(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveDeadline = (deadlineId: string) => {
|
||||||
|
setTrackedDeadlines((prev) => prev.filter((d) => d.id !== deadlineId));
|
||||||
|
};
|
||||||
|
|
||||||
// ── Sender/Recipient autocomplete suggestions ──
|
// ── Sender/Recipient autocomplete suggestions ──
|
||||||
const [senderFocused, setSenderFocused] = useState(false);
|
const [senderFocused, setSenderFocused] = useState(false);
|
||||||
@@ -111,6 +149,7 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
|
|||||||
deadline: deadline || undefined,
|
deadline: deadline || undefined,
|
||||||
linkedEntryIds,
|
linkedEntryIds,
|
||||||
attachments,
|
attachments,
|
||||||
|
trackedDeadlines: trackedDeadlines.length > 0 ? trackedDeadlines : undefined,
|
||||||
notes,
|
notes,
|
||||||
tags: initial?.tags ?? [],
|
tags: initial?.tags ?? [],
|
||||||
visibility: initial?.visibility ?? 'all',
|
visibility: initial?.visibility ?? 'all',
|
||||||
@@ -278,6 +317,50 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Tracked Deadlines */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="flex items-center gap-1.5">
|
||||||
|
<Clock className="h-3.5 w-3.5" />
|
||||||
|
Termene legale
|
||||||
|
</Label>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setDeadlineAddOpen(true)}>
|
||||||
|
<Plus className="mr-1 h-3.5 w-3.5" /> Adaugă termen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{trackedDeadlines.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{trackedDeadlines.map((dl) => (
|
||||||
|
<DeadlineCard
|
||||||
|
key={dl.id}
|
||||||
|
deadline={dl}
|
||||||
|
onResolve={setResolvingDeadline}
|
||||||
|
onRemove={handleRemoveDeadline}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{trackedDeadlines.length === 0 && (
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
Niciun termen legal. Apăsați "Adaugă termen" pentru a urmări un termen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeadlineAddDialog
|
||||||
|
open={deadlineAddOpen}
|
||||||
|
onOpenChange={setDeadlineAddOpen}
|
||||||
|
entryDate={date}
|
||||||
|
onAdd={handleAddDeadline}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeadlineResolveDialog
|
||||||
|
open={resolvingDeadline !== null}
|
||||||
|
deadline={resolvingDeadline}
|
||||||
|
onOpenChange={(open) => { if (!open) setResolvingDeadline(null); }}
|
||||||
|
onResolve={handleResolveDeadline}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Attachments */}
|
{/* Attachments */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Pencil, Trash2, CheckCircle2, Link2 } from 'lucide-react';
|
import { Pencil, Trash2, CheckCircle2, Link2, Clock } from 'lucide-react';
|
||||||
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 type { RegistryEntry, DocumentType } from '../types';
|
import type { RegistryEntry, DocumentType } from '../types';
|
||||||
@@ -100,6 +100,12 @@ export function RegistryTable({ entries, loading, onEdit, onDelete, onClose }: R
|
|||||||
{entry.attachments.length} fișiere
|
{entry.attachments.length} fișiere
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{(entry.trackedDeadlines ?? []).length > 0 && (
|
||||||
|
<Badge variant="outline" className="ml-1 text-[10px] px-1">
|
||||||
|
<Clock className="mr-0.5 inline h-2.5 w-2.5" />
|
||||||
|
{(entry.trackedDeadlines ?? []).length}
|
||||||
|
</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">{entry.sender}</td>
|
||||||
<td className="px-3 py-2 max-w-[130px] truncate">{entry.recipient}</td>
|
<td className="px-3 py-2 max-w-[130px] truncate">{entry.recipient}</td>
|
||||||
|
|||||||
28
src/modules/registratura/hooks/use-deadline-filters.ts
Normal file
28
src/modules/registratura/hooks/use-deadline-filters.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import type { DeadlineCategory, DeadlineResolution } from '../types';
|
||||||
|
|
||||||
|
export interface DeadlineFilters {
|
||||||
|
category: DeadlineCategory | 'all';
|
||||||
|
resolution: DeadlineResolution | 'all';
|
||||||
|
urgentOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeadlineFilters() {
|
||||||
|
const [filters, setFilters] = useState<DeadlineFilters>({
|
||||||
|
category: 'all',
|
||||||
|
resolution: 'all',
|
||||||
|
urgentOnly: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateFilter = useCallback(<K extends keyof DeadlineFilters>(key: K, value: DeadlineFilters[K]) => {
|
||||||
|
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetFilters = useCallback(() => {
|
||||||
|
setFilters({ category: 'all', resolution: 'all', urgentOnly: false });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { filters, updateFilter, resetFilters };
|
||||||
|
}
|
||||||
@@ -3,8 +3,10 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useStorage } from '@/core/storage';
|
import { useStorage } from '@/core/storage';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import type { RegistryEntry, RegistryDirection, RegistryStatus, DocumentType } from '../types';
|
import type { RegistryEntry, RegistryDirection, RegistryStatus, DocumentType, TrackedDeadline, DeadlineResolution } from '../types';
|
||||||
import { getAllEntries, saveEntry, deleteEntry, generateRegistryNumber } from '../services/registry-service';
|
import { getAllEntries, saveEntry, deleteEntry, generateRegistryNumber } from '../services/registry-service';
|
||||||
|
import { createTrackedDeadline, resolveDeadline as resolveDeadlineFn } from '../services/deadline-service';
|
||||||
|
import { getDeadlineType } from '../services/deadline-catalog';
|
||||||
|
|
||||||
export interface RegistryFilters {
|
export interface RegistryFilters {
|
||||||
search: string;
|
search: string;
|
||||||
@@ -97,6 +99,71 @@ export function useRegistry() {
|
|||||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ── Deadline operations ──
|
||||||
|
|
||||||
|
const addDeadline = useCallback(async (entryId: string, typeId: string, startDate: string, chainParentId?: string) => {
|
||||||
|
const entry = entries.find((e) => e.id === entryId);
|
||||||
|
if (!entry) return null;
|
||||||
|
const tracked = createTrackedDeadline(typeId, startDate, chainParentId);
|
||||||
|
if (!tracked) return null;
|
||||||
|
const existing = entry.trackedDeadlines ?? [];
|
||||||
|
const updated: RegistryEntry = {
|
||||||
|
...entry,
|
||||||
|
trackedDeadlines: [...existing, tracked],
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await saveEntry(storage, updated);
|
||||||
|
await refresh();
|
||||||
|
return tracked;
|
||||||
|
}, [entries, storage, refresh]);
|
||||||
|
|
||||||
|
const resolveDeadline = useCallback(async (
|
||||||
|
entryId: string,
|
||||||
|
deadlineId: string,
|
||||||
|
resolution: DeadlineResolution,
|
||||||
|
note?: string,
|
||||||
|
): Promise<TrackedDeadline | null> => {
|
||||||
|
const entry = entries.find((e) => e.id === entryId);
|
||||||
|
if (!entry) return null;
|
||||||
|
const deadlines = entry.trackedDeadlines ?? [];
|
||||||
|
const idx = deadlines.findIndex((d) => d.id === deadlineId);
|
||||||
|
if (idx === -1) return null;
|
||||||
|
const dl = deadlines[idx];
|
||||||
|
if (!dl) return null;
|
||||||
|
const resolved = resolveDeadlineFn(dl, resolution, note);
|
||||||
|
const updatedDeadlines = [...deadlines];
|
||||||
|
updatedDeadlines[idx] = resolved;
|
||||||
|
const updated: RegistryEntry = {
|
||||||
|
...entry,
|
||||||
|
trackedDeadlines: updatedDeadlines,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await saveEntry(storage, updated);
|
||||||
|
|
||||||
|
// If the resolved deadline has a chain, automatically check for the next type
|
||||||
|
const def = getDeadlineType(dl.typeId);
|
||||||
|
await refresh();
|
||||||
|
|
||||||
|
if (def?.chainNextTypeId && (resolution === 'completed' || resolution === 'aprobat-tacit')) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}, [entries, storage, refresh]);
|
||||||
|
|
||||||
|
const removeDeadline = useCallback(async (entryId: string, deadlineId: string) => {
|
||||||
|
const entry = entries.find((e) => e.id === entryId);
|
||||||
|
if (!entry) return;
|
||||||
|
const deadlines = entry.trackedDeadlines ?? [];
|
||||||
|
const updated: RegistryEntry = {
|
||||||
|
...entry,
|
||||||
|
trackedDeadlines: deadlines.filter((d) => d.id !== deadlineId),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await saveEntry(storage, updated);
|
||||||
|
await refresh();
|
||||||
|
}, [entries, storage, refresh]);
|
||||||
|
|
||||||
const filteredEntries = entries.filter((entry) => {
|
const filteredEntries = entries.filter((entry) => {
|
||||||
if (filters.direction !== 'all' && entry.direction !== filters.direction) return false;
|
if (filters.direction !== 'all' && entry.direction !== filters.direction) return false;
|
||||||
if (filters.status !== 'all' && entry.status !== filters.status) return false;
|
if (filters.status !== 'all' && entry.status !== filters.status) return false;
|
||||||
@@ -124,6 +191,9 @@ export function useRegistry() {
|
|||||||
updateEntry,
|
updateEntry,
|
||||||
removeEntry,
|
removeEntry,
|
||||||
closeEntry,
|
closeEntry,
|
||||||
|
addDeadline,
|
||||||
|
resolveDeadline,
|
||||||
|
removeDeadline,
|
||||||
refresh,
|
refresh,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
export { registraturaConfig } from './config';
|
export { registraturaConfig } from './config';
|
||||||
export { RegistraturaModule } from './components/registratura-module';
|
export { RegistraturaModule } from './components/registratura-module';
|
||||||
export type { RegistryEntry, RegistryDirection, RegistryStatus, DocumentType } from './types';
|
export type {
|
||||||
|
RegistryEntry, RegistryDirection, RegistryStatus, DocumentType,
|
||||||
|
DeadlineDayType, DeadlineResolution, DeadlineCategory,
|
||||||
|
DeadlineTypeDef, TrackedDeadline,
|
||||||
|
} from './types';
|
||||||
|
|||||||
220
src/modules/registratura/services/deadline-catalog.ts
Normal file
220
src/modules/registratura/services/deadline-catalog.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import type { DeadlineTypeDef, DeadlineCategory } from '../types';
|
||||||
|
|
||||||
|
export const DEADLINE_CATALOG: DeadlineTypeDef[] = [
|
||||||
|
// ── Avize ──
|
||||||
|
{
|
||||||
|
id: 'cerere-cu',
|
||||||
|
label: 'Cerere CU',
|
||||||
|
description: 'Termen de emitere a Certificatului de Urbanism de la data depunerii cererii.',
|
||||||
|
days: 15,
|
||||||
|
dayType: 'calendar',
|
||||||
|
startDateLabel: 'Data depunerii',
|
||||||
|
requiresCustomStartDate: false,
|
||||||
|
tacitApprovalApplicable: true,
|
||||||
|
category: 'avize',
|
||||||
|
legalReference: 'Legea 50/1991, art. 6¹',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'avize-normale',
|
||||||
|
label: 'Cerere Avize normale',
|
||||||
|
description: 'Termen de emitere a avizelor de la data depunerii cererii.',
|
||||||
|
days: 15,
|
||||||
|
dayType: 'calendar',
|
||||||
|
startDateLabel: 'Data depunerii',
|
||||||
|
requiresCustomStartDate: false,
|
||||||
|
tacitApprovalApplicable: true,
|
||||||
|
category: 'avize',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'aviz-cultura',
|
||||||
|
label: 'Aviz Cultură',
|
||||||
|
description: 'Termen de emitere a avizului Ministerului Culturii de la data comisiei.',
|
||||||
|
days: 30,
|
||||||
|
dayType: 'calendar',
|
||||||
|
startDateLabel: 'Data comisie',
|
||||||
|
requiresCustomStartDate: true,
|
||||||
|
startDateHint: 'Data ședinței comisiei de specialitate',
|
||||||
|
tacitApprovalApplicable: true,
|
||||||
|
category: 'avize',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'aviz-mediu',
|
||||||
|
label: 'Aviz Mediu',
|
||||||
|
description: 'Termen de emitere a avizului de mediu de la finalizarea procedurilor.',
|
||||||
|
days: 15,
|
||||||
|
dayType: 'calendar',
|
||||||
|
startDateLabel: 'Data finalizare proceduri',
|
||||||
|
requiresCustomStartDate: true,
|
||||||
|
startDateHint: 'Data finalizării procedurii de evaluare de mediu',
|
||||||
|
tacitApprovalApplicable: true,
|
||||||
|
category: 'avize',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'aviz-aeronautica',
|
||||||
|
label: 'Aviz Aeronautică',
|
||||||
|
description: 'Termen de emitere a avizului de la Autoritatea Aeronautică.',
|
||||||
|
days: 30,
|
||||||
|
dayType: 'calendar',
|
||||||
|
startDateLabel: 'Data depunerii',
|
||||||
|
requiresCustomStartDate: false,
|
||||||
|
tacitApprovalApplicable: true,
|
||||||
|
category: 'avize',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Completări ──
|
||||||
|
{
|
||||||
|
id: 'completare-beneficiar',
|
||||||
|
label: 'Completare — termen beneficiar',
|
||||||
|
description: 'Termen acordat beneficiarului pentru completarea documentației.',
|
||||||
|
days: 60,
|
||||||
|
dayType: 'calendar',
|
||||||
|
startDateLabel: 'Data notificării',
|
||||||
|
requiresCustomStartDate: false,
|
||||||
|
tacitApprovalApplicable: false,
|
||||||
|
chainNextTypeId: 'completare-emitere',
|
||||||
|
chainNextActionLabel: 'Adaugă termen emitere (15 zile)',
|
||||||
|
category: 'completari',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'completare-emitere',
|
||||||
|
label: 'Completare — termen emitere',
|
||||||
|
description: 'Termen de emitere după depunerea completărilor.',
|
||||||
|
days: 15,
|
||||||
|
dayType: 'calendar',
|
||||||
|
startDateLabel: 'Data depunere completări',
|
||||||
|
requiresCustomStartDate: true,
|
||||||
|
startDateHint: 'Data la care beneficiarul a depus completările',
|
||||||
|
tacitApprovalApplicable: true,
|
||||||
|
category: 'completari',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Analiză ──
|
||||||
|
{
|
||||||
|
id: 'ctatu-analiza',
|
||||||
|
label: 'Analiză CTATU',
|
||||||
|
description: 'Termen de analiză în Comisia Tehnică de Amenajare a Teritoriului și Urbanism.',
|
||||||
|
days: 30,
|
||||||
|
dayType: 'calendar',
|
||||||
|
startDateLabel: 'Data depunerii',
|
||||||
|
requiresCustomStartDate: false,
|
||||||
|
tacitApprovalApplicable: false,
|
||||||
|
category: 'analiza',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'consiliu-promovare',
|
||||||
|
label: 'Promovare Consiliu Local',
|
||||||
|
description: 'Termen de promovare în ședința Consiliului Local.',
|
||||||
|
days: 30,
|
||||||
|
dayType: 'calendar',
|
||||||
|
startDateLabel: 'Data depunerii',
|
||||||
|
requiresCustomStartDate: false,
|
||||||
|
tacitApprovalApplicable: false,
|
||||||
|
category: 'analiza',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'consiliu-vot',
|
||||||
|
label: 'Vot Consiliu Local',
|
||||||
|
description: 'Termen de vot în Consiliu Local de la finalizarea dezbaterii publice.',
|
||||||
|
days: 45,
|
||||||
|
dayType: 'calendar',
|
||||||
|
startDateLabel: 'Data finalizare dezbatere',
|
||||||
|
requiresCustomStartDate: true,
|
||||||
|
startDateHint: 'Data finalizării dezbaterii publice',
|
||||||
|
tacitApprovalApplicable: false,
|
||||||
|
category: 'analiza',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Autorizare ──
|
||||||
|
{
|
||||||
|
id: 'verificare-ac',
|
||||||
|
label: 'Verificare AC',
|
||||||
|
description: 'Termen de verificare a documentației pentru Autorizația de Construire.',
|
||||||
|
days: 5,
|
||||||
|
dayType: 'working',
|
||||||
|
startDateLabel: 'Data depunerii',
|
||||||
|
requiresCustomStartDate: false,
|
||||||
|
tacitApprovalApplicable: false,
|
||||||
|
category: 'autorizare',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prelungire-ac',
|
||||||
|
label: 'Cerere prelungire AC',
|
||||||
|
description: 'Cererea de prelungire trebuie depusă cu minim 45 zile lucrătoare ÎNAINTE de expirarea AC.',
|
||||||
|
days: 45,
|
||||||
|
dayType: 'working',
|
||||||
|
startDateLabel: 'Data expirare AC',
|
||||||
|
requiresCustomStartDate: true,
|
||||||
|
startDateHint: 'Data de expirare a Autorizației de Construire',
|
||||||
|
tacitApprovalApplicable: false,
|
||||||
|
category: 'autorizare',
|
||||||
|
isBackwardDeadline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prelungire-ac-comunicare',
|
||||||
|
label: 'Comunicare decizie prelungire',
|
||||||
|
description: 'Termen de comunicare a deciziei privind prelungirea AC.',
|
||||||
|
days: 15,
|
||||||
|
dayType: 'working',
|
||||||
|
startDateLabel: 'Data depunere cerere',
|
||||||
|
requiresCustomStartDate: true,
|
||||||
|
startDateHint: 'Data depunerii cererii de prelungire',
|
||||||
|
tacitApprovalApplicable: false,
|
||||||
|
category: 'autorizare',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Publicitate ──
|
||||||
|
{
|
||||||
|
id: 'publicitate-ac',
|
||||||
|
label: 'Publicitate AC',
|
||||||
|
description: 'Termen de publicitate a Autorizației de Construire.',
|
||||||
|
days: 30,
|
||||||
|
dayType: 'calendar',
|
||||||
|
startDateLabel: 'Data emitere AC',
|
||||||
|
requiresCustomStartDate: true,
|
||||||
|
startDateHint: 'Data emiterii Autorizației de Construire',
|
||||||
|
tacitApprovalApplicable: false,
|
||||||
|
category: 'publicitate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'plangere-prealabila',
|
||||||
|
label: 'Plângere prealabilă',
|
||||||
|
description: 'Termen de depunere a plângerii prealabile.',
|
||||||
|
days: 30,
|
||||||
|
dayType: 'calendar',
|
||||||
|
startDateLabel: 'Data ultimă publicitate',
|
||||||
|
requiresCustomStartDate: true,
|
||||||
|
startDateHint: 'Data ultimei publicități / aduceri la cunoștință',
|
||||||
|
tacitApprovalApplicable: false,
|
||||||
|
chainNextTypeId: 'contestare-instanta',
|
||||||
|
chainNextActionLabel: 'Adaugă termen contestare instanță (60 zile)',
|
||||||
|
category: 'publicitate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'contestare-instanta',
|
||||||
|
label: 'Contestare în instanță',
|
||||||
|
description: 'Termen de contestare în instanța de contencios administrativ.',
|
||||||
|
days: 60,
|
||||||
|
dayType: 'calendar',
|
||||||
|
startDateLabel: 'Data răspuns plângere',
|
||||||
|
requiresCustomStartDate: true,
|
||||||
|
startDateHint: 'Data primirii răspunsului la plângerea prealabilă',
|
||||||
|
tacitApprovalApplicable: false,
|
||||||
|
category: 'publicitate',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CATEGORY_LABELS: Record<DeadlineCategory, string> = {
|
||||||
|
avize: 'Avize',
|
||||||
|
completari: 'Completări',
|
||||||
|
analiza: 'Analiză',
|
||||||
|
autorizare: 'Autorizare',
|
||||||
|
publicitate: 'Publicitate',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDeadlineType(typeId: string): DeadlineTypeDef | undefined {
|
||||||
|
return DEADLINE_CATALOG.find((d) => d.id === typeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDeadlinesByCategory(category: DeadlineCategory): DeadlineTypeDef[] {
|
||||||
|
return DEADLINE_CATALOG.filter((d) => d.category === category);
|
||||||
|
}
|
||||||
146
src/modules/registratura/services/deadline-service.ts
Normal file
146
src/modules/registratura/services/deadline-service.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import type { TrackedDeadline, DeadlineResolution, RegistryEntry } from '../types';
|
||||||
|
import { getDeadlineType } from './deadline-catalog';
|
||||||
|
import { computeDueDate } from './working-days';
|
||||||
|
|
||||||
|
export interface DeadlineDisplayStatus {
|
||||||
|
label: string;
|
||||||
|
variant: 'green' | 'yellow' | 'red' | 'blue' | 'gray';
|
||||||
|
daysRemaining: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new tracked deadline from a type definition + start date.
|
||||||
|
*/
|
||||||
|
export function createTrackedDeadline(
|
||||||
|
typeId: string,
|
||||||
|
startDate: string,
|
||||||
|
chainParentId?: string,
|
||||||
|
): TrackedDeadline | null {
|
||||||
|
const def = getDeadlineType(typeId);
|
||||||
|
if (!def) return null;
|
||||||
|
|
||||||
|
const start = new Date(startDate);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const due = computeDueDate(start, def.days, def.dayType, def.isBackwardDeadline);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: uuid(),
|
||||||
|
typeId,
|
||||||
|
startDate,
|
||||||
|
dueDate: formatDate(due),
|
||||||
|
resolution: 'pending',
|
||||||
|
chainParentId,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a deadline with a given resolution.
|
||||||
|
*/
|
||||||
|
export function resolveDeadline(
|
||||||
|
deadline: TrackedDeadline,
|
||||||
|
resolution: DeadlineResolution,
|
||||||
|
note?: string,
|
||||||
|
): TrackedDeadline {
|
||||||
|
return {
|
||||||
|
...deadline,
|
||||||
|
resolution,
|
||||||
|
resolvedDate: new Date().toISOString(),
|
||||||
|
resolutionNote: note,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the display status for a tracked deadline — color coding + label.
|
||||||
|
*/
|
||||||
|
export function getDeadlineDisplayStatus(deadline: TrackedDeadline): DeadlineDisplayStatus {
|
||||||
|
const def = getDeadlineType(deadline.typeId);
|
||||||
|
|
||||||
|
// Already resolved
|
||||||
|
if (deadline.resolution !== 'pending') {
|
||||||
|
if (deadline.resolution === 'aprobat-tacit') {
|
||||||
|
return { label: 'Aprobat tacit', variant: 'blue', daysRemaining: null };
|
||||||
|
}
|
||||||
|
if (deadline.resolution === 'respins') {
|
||||||
|
return { label: 'Respins', variant: 'gray', daysRemaining: null };
|
||||||
|
}
|
||||||
|
if (deadline.resolution === 'anulat') {
|
||||||
|
return { label: 'Anulat', variant: 'gray', daysRemaining: null };
|
||||||
|
}
|
||||||
|
return { label: 'Finalizat', variant: 'gray', daysRemaining: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending — compute days remaining
|
||||||
|
const now = new Date();
|
||||||
|
now.setHours(0, 0, 0, 0);
|
||||||
|
const due = new Date(deadline.dueDate);
|
||||||
|
due.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const diff = due.getTime() - now.getTime();
|
||||||
|
const daysRemaining = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
// Overdue + tacit applicable → tacit approval
|
||||||
|
if (daysRemaining < 0 && def?.tacitApprovalApplicable) {
|
||||||
|
return { label: 'Aprobat tacit', variant: 'blue', daysRemaining };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (daysRemaining < 0) {
|
||||||
|
return { label: 'Depășit termen', variant: 'red', daysRemaining };
|
||||||
|
}
|
||||||
|
if (daysRemaining <= 5) {
|
||||||
|
return { label: 'Urgent', variant: 'yellow', daysRemaining };
|
||||||
|
}
|
||||||
|
return { label: 'În termen', variant: 'green', daysRemaining };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate deadline stats across all entries.
|
||||||
|
*/
|
||||||
|
export function aggregateDeadlines(entries: RegistryEntry[]): {
|
||||||
|
active: number;
|
||||||
|
urgent: number;
|
||||||
|
overdue: number;
|
||||||
|
tacit: number;
|
||||||
|
all: Array<{ deadline: TrackedDeadline; entry: RegistryEntry; status: DeadlineDisplayStatus }>;
|
||||||
|
} {
|
||||||
|
let active = 0;
|
||||||
|
let urgent = 0;
|
||||||
|
let overdue = 0;
|
||||||
|
let tacit = 0;
|
||||||
|
const all: Array<{ deadline: TrackedDeadline; entry: RegistryEntry; status: DeadlineDisplayStatus }> = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
for (const dl of entry.trackedDeadlines ?? []) {
|
||||||
|
const status = getDeadlineDisplayStatus(dl);
|
||||||
|
all.push({ deadline: dl, entry, status });
|
||||||
|
|
||||||
|
if (dl.resolution === 'pending') {
|
||||||
|
active++;
|
||||||
|
if (status.variant === 'yellow') urgent++;
|
||||||
|
if (status.variant === 'red') overdue++;
|
||||||
|
if (status.variant === 'blue') tacit++;
|
||||||
|
} else if (dl.resolution === 'aprobat-tacit') {
|
||||||
|
tacit++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: overdue first, then by due date ascending
|
||||||
|
all.sort((a, b) => {
|
||||||
|
const aP = a.deadline.resolution === 'pending' ? 0 : 1;
|
||||||
|
const bP = b.deadline.resolution === 'pending' ? 0 : 1;
|
||||||
|
if (aP !== bP) return aP - bP;
|
||||||
|
return a.deadline.dueDate.localeCompare(b.deadline.dueDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { active, urgent, overdue, tacit, all };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d: Date): string {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
146
src/modules/registratura/services/working-days.ts
Normal file
146
src/modules/registratura/services/working-days.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Romanian working-day arithmetic.
|
||||||
|
*
|
||||||
|
* Fixed public holidays + Orthodox Easter-derived moveable feasts.
|
||||||
|
* Uses the Meeus algorithm for Orthodox Easter computation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Fixed Romanian public holidays (month is 0-indexed) ──
|
||||||
|
|
||||||
|
interface FixedHoliday {
|
||||||
|
month: number;
|
||||||
|
day: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIXED_HOLIDAYS: FixedHoliday[] = [
|
||||||
|
{ month: 0, day: 1 }, // Anul Nou
|
||||||
|
{ month: 0, day: 2 }, // Anul Nou (zi 2)
|
||||||
|
{ month: 0, day: 24 }, // Ziua Unirii
|
||||||
|
{ month: 4, day: 1 }, // Ziua Muncii
|
||||||
|
{ month: 5, day: 1 }, // Ziua Copilului
|
||||||
|
{ month: 7, day: 15 }, // Adormirea Maicii Domnului
|
||||||
|
{ month: 10, day: 30 }, // Sfântul Andrei
|
||||||
|
{ month: 11, day: 1 }, // Ziua Națională
|
||||||
|
{ month: 11, day: 25 }, // Crăciunul
|
||||||
|
{ month: 11, day: 26 }, // Crăciunul (zi 2)
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Orthodox Easter via Meeus algorithm ──
|
||||||
|
|
||||||
|
function orthodoxEaster(year: number): Date {
|
||||||
|
const a = year % 4;
|
||||||
|
const b = year % 7;
|
||||||
|
const c = year % 19;
|
||||||
|
const d = (19 * c + 15) % 30;
|
||||||
|
const e = (2 * a + 4 * b - d + 34) % 7;
|
||||||
|
const month = Math.floor((d + e + 114) / 31); // 3 = March, 4 = April
|
||||||
|
const day = ((d + e + 114) % 31) + 1;
|
||||||
|
|
||||||
|
// Julian date — convert to Gregorian by adding 13 days (valid 1900–2099)
|
||||||
|
const julian = new Date(year, month - 1, day);
|
||||||
|
julian.setDate(julian.getDate() + 13);
|
||||||
|
return julian;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMovableHolidays(year: number): Date[] {
|
||||||
|
const easter = orthodoxEaster(year);
|
||||||
|
|
||||||
|
const goodFriday = new Date(easter);
|
||||||
|
goodFriday.setDate(easter.getDate() - 2);
|
||||||
|
|
||||||
|
const easterMonday = new Date(easter);
|
||||||
|
easterMonday.setDate(easter.getDate() + 1);
|
||||||
|
|
||||||
|
const rusaliiSunday = new Date(easter);
|
||||||
|
rusaliiSunday.setDate(easter.getDate() + 49);
|
||||||
|
|
||||||
|
const rusaliiMonday = new Date(easter);
|
||||||
|
rusaliiMonday.setDate(easter.getDate() + 50);
|
||||||
|
|
||||||
|
return [goodFriday, easter, easterMonday, rusaliiSunday, rusaliiMonday];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Holiday cache per year ──
|
||||||
|
|
||||||
|
const holidayCache = new Map<number, Set<string>>();
|
||||||
|
|
||||||
|
function toKey(d: Date): string {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHolidaySet(year: number): Set<string> {
|
||||||
|
const cached = holidayCache.get(year);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const set = new Set<string>();
|
||||||
|
|
||||||
|
for (const h of FIXED_HOLIDAYS) {
|
||||||
|
set.add(toKey(new Date(year, h.month, h.day)));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const d of getMovableHolidays(year)) {
|
||||||
|
set.add(toKey(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
holidayCache.set(year, set);
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ──
|
||||||
|
|
||||||
|
export function isPublicHoliday(date: Date): boolean {
|
||||||
|
return getHolidaySet(date.getFullYear()).has(toKey(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWeekend(date: Date): boolean {
|
||||||
|
const day = date.getDay();
|
||||||
|
return day === 0 || day === 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWorkingDay(date: Date): boolean {
|
||||||
|
return !isWeekend(date) && !isPublicHoliday(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add calendar days (simply skips no days).
|
||||||
|
* Supports negative values.
|
||||||
|
*/
|
||||||
|
export function addCalendarDays(start: Date, days: number): Date {
|
||||||
|
const result = new Date(start);
|
||||||
|
result.setDate(result.getDate() + days);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add working days, skipping weekends and Romanian public holidays.
|
||||||
|
* Supports negative values (for backward deadlines).
|
||||||
|
*/
|
||||||
|
export function addWorkingDays(start: Date, days: number): Date {
|
||||||
|
const result = new Date(start);
|
||||||
|
const direction = days >= 0 ? 1 : -1;
|
||||||
|
let remaining = Math.abs(days);
|
||||||
|
|
||||||
|
while (remaining > 0) {
|
||||||
|
result.setDate(result.getDate() + direction);
|
||||||
|
if (isWorkingDay(result)) {
|
||||||
|
remaining--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the due date for a deadline definition.
|
||||||
|
*/
|
||||||
|
export function computeDueDate(
|
||||||
|
startDate: Date,
|
||||||
|
days: number,
|
||||||
|
dayType: 'calendar' | 'working',
|
||||||
|
isBackward?: boolean,
|
||||||
|
): Date {
|
||||||
|
const effectiveDays = isBackward ? -days : days;
|
||||||
|
return dayType === 'working'
|
||||||
|
? addWorkingDays(startDate, effectiveDays)
|
||||||
|
: addCalendarDays(startDate, effectiveDays);
|
||||||
|
}
|
||||||
@@ -30,6 +30,44 @@ export interface RegistryAttachment {
|
|||||||
addedAt: string;
|
addedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Deadline tracking types ──
|
||||||
|
|
||||||
|
export type DeadlineDayType = 'calendar' | 'working';
|
||||||
|
|
||||||
|
export type DeadlineResolution = 'pending' | 'completed' | 'aprobat-tacit' | 'respins' | 'anulat';
|
||||||
|
|
||||||
|
export type DeadlineCategory = 'avize' | 'completari' | 'analiza' | 'autorizare' | 'publicitate';
|
||||||
|
|
||||||
|
export interface DeadlineTypeDef {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
days: number;
|
||||||
|
dayType: DeadlineDayType;
|
||||||
|
startDateLabel: string;
|
||||||
|
requiresCustomStartDate: boolean;
|
||||||
|
startDateHint?: string;
|
||||||
|
tacitApprovalApplicable: boolean;
|
||||||
|
tacitApprovalExcludable?: boolean;
|
||||||
|
chainNextTypeId?: string;
|
||||||
|
chainNextActionLabel?: string;
|
||||||
|
legalReference?: string;
|
||||||
|
category: DeadlineCategory;
|
||||||
|
isBackwardDeadline?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackedDeadline {
|
||||||
|
id: string;
|
||||||
|
typeId: string;
|
||||||
|
startDate: string;
|
||||||
|
dueDate: string;
|
||||||
|
resolution: DeadlineResolution;
|
||||||
|
resolvedDate?: string;
|
||||||
|
resolutionNote?: string;
|
||||||
|
chainParentId?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RegistryEntry {
|
export interface RegistryEntry {
|
||||||
id: string;
|
id: string;
|
||||||
/** Company-specific number: B-0001/2026, US-0001/2026, SDT-0001/2026 */
|
/** Company-specific number: B-0001/2026, US-0001/2026, SDT-0001/2026 */
|
||||||
@@ -52,6 +90,8 @@ export interface RegistryEntry {
|
|||||||
linkedEntryIds: string[];
|
linkedEntryIds: string[];
|
||||||
/** File attachments */
|
/** File attachments */
|
||||||
attachments: RegistryAttachment[];
|
attachments: RegistryAttachment[];
|
||||||
|
/** Tracked legal deadlines */
|
||||||
|
trackedDeadlines?: TrackedDeadline[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
notes: string;
|
notes: string;
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
|
|||||||
Reference in New Issue
Block a user