Introduce an information system for important announcements

Implement a new announcement system with priority levels, role-based access, and store-specific filtering, replacing the customer order statistics card on the dashboard.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e437ffed-c31b-44eb-9fd5-b6f9faa8e7ce
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/1957c339-2757-4d1f-8e92-e9f71a1ce58e/e437ffed-c31b-44eb-9fd5-b6f9faa8e7ce/biGr9ER
This commit is contained in:
michaelschal committed 2025-08-16 09:54:34 +00:00
1 parent 5688450117
commit 30cfc4b3de
9 files changed
+729 -47

No files matched your search

+309
View File
@@ -0,0 +1,309 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuthUnified } from "@/hooks/useAuthUnified";
import { useStore } from "@/components/Layout";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Megaphone, Plus, X, AlertTriangle, Info, Bell } from "lucide-react";
import { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { insertAnnouncementSchema } from "@shared/schema";
import type { AnnouncementWithRelations } from "@shared/schema";
import { z } from "zod";
import { safeFormat, safeDate } from "@/lib/dateUtils";
const formSchema = insertAnnouncementSchema.extend({
groupId: z.coerce.number().optional(),
});
export default function AnnouncementCard() {
const { user } = useAuthUnified();
const { selectedStoreId } = useStore();
const queryClient = useQueryClient();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
content: "",
priority: "normal",
groupId: undefined,
},
});
// Fetch announcements
const { data: announcements = [] } = useQuery<AnnouncementWithRelations[]>({
queryKey: ['/api/announcements', selectedStoreId],
queryFn: async () => {
const params = new URLSearchParams();
if (selectedStoreId && user?.role === 'admin') {
params.append('storeId', selectedStoreId.toString());
}
const response = await fetch(`/api/announcements?${params}`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error('Failed to fetch announcements');
}
return response.json();
},
});
// Fetch groups for admin
const { data: groups = [] } = useQuery<any[]>({
queryKey: ['/api/groups'],
enabled: user?.role === 'admin',
});
// Create announcement mutation
const createMutation = useMutation({
mutationFn: async (data: z.infer<typeof formSchema>) => {
const response = await fetch('/api/announcements', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Failed to create announcement');
}
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/announcements'] });
setIsCreateDialogOpen(false);
form.reset();
},
});
// Delete announcement mutation
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
const response = await fetch(`/api/announcements/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) {
throw new Error('Failed to delete announcement');
}
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/announcements'] });
},
});
const onSubmit = (data: z.infer<typeof formSchema>) => {
createMutation.mutate(data);
};
const getPriorityIcon = (priority: string) => {
switch (priority) {
case 'urgent':
return <AlertTriangle className="h-4 w-4" />;
case 'important':
return <Bell className="h-4 w-4" />;
default:
return <Info className="h-4 w-4" />;
}
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'urgent':
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
case 'important':
return 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200';
default:
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
}
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<div className="flex items-center space-x-2">
<Megaphone className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-lg">Informations</CardTitle>
</div>
{user?.role === 'admin' && (
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<Plus className="h-4 w-4 mr-1" />
Nouveau
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Nouvelle information</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Titre</FormLabel>
<FormControl>
<Input placeholder="Titre de l'information" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>Contenu</FormLabel>
<FormControl>
<Textarea
placeholder="Contenu de l'information"
rows={4}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priorité</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Sélectionner une priorité" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="normal">Normale</SelectItem>
<SelectItem value="important">Importante</SelectItem>
<SelectItem value="urgent">Urgente</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="groupId"
render={({ field }) => (
<FormItem>
<FormLabel>Magasin (optionnel)</FormLabel>
<Select onValueChange={(value) => field.onChange(value ? parseInt(value) : undefined)}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Tous les magasins" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="">Tous les magasins</SelectItem>
{groups.map((group: any) => (
<SelectItem key={group.id} value={group.id.toString()}>
{group.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end space-x-2 pt-4">
<Button
type="button"
variant="outline"
onClick={() => setIsCreateDialogOpen(false)}
>
Annuler
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Création...' : 'Créer'}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
)}
</CardHeader>
<CardContent>
{announcements.length === 0 ? (
<div className="text-center text-muted-foreground py-6">
<Megaphone className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>Aucune information disponible</p>
</div>
) : (
<div className="space-y-3 max-h-80 overflow-y-auto">
{announcements.map((announcement) => (
<div
key={announcement.id}
className="p-3 bg-muted/50 rounded-lg space-y-2"
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2 mb-1">
{getPriorityIcon(announcement.priority)}
<h4 className="font-medium text-sm truncate">
{announcement.title}
</h4>
<Badge variant="secondary" className={getPriorityColor(announcement.priority)}>
{announcement.priority === 'urgent' ? 'Urgent' :
announcement.priority === 'important' ? 'Important' : 'Normal'}
</Badge>
</div>
<p className="text-sm text-muted-foreground line-clamp-2">
{announcement.content}
</p>
<div className="flex items-center justify-between text-xs text-muted-foreground mt-2">
<span>
Par {announcement.author.firstName} {announcement.author.lastName}
{announcement.group && ` • ${announcement.group.name}`}
</span>
<span>
{safeFormat(safeDate(announcement.createdAt), 'dd/MM/yyyy HH:mm')}
</span>
</div>
</div>
{user?.role === 'admin' && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 ml-2 flex-shrink-0"
onClick={() => deleteMutation.mutate(announcement.id)}
disabled={deleteMutation.isPending}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
+3 -46
View File
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Calendar, Package, ShoppingCart, TrendingUp, Clock, MapPin, User, AlertTriangle, CheckCircle, Truck, FileText, BarChart3, Megaphone, Shield, XCircle, CheckSquare, Circle } from "lucide-react";
import { safeFormat, safeDate } from "@/lib/dateUtils";
import type { PublicityWithRelations } from "@shared/schema";
import AnnouncementCard from "@/components/AnnouncementCard";
export default function Dashboard() {
const { user } = useAuthUnified();
@@ -476,52 +477,8 @@ export default function Dashboard() {
{/* Section Rapprochement BL */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
{/* Statistiques des commandes clients */}
<Card className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow">
<CardHeader className="pb-4 border-b border-gray-100">
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center">
<User className="h-5 w-5 mr-3 text-purple-600" />
Commandes Clients
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 p-6">
<div className="flex items-center justify-between p-3 hover:bg-gray-50 transition-colors">
<div className="flex items-center space-x-3">
<div className="h-3 w-3 bg-red-500"></div>
<span className="text-sm font-medium text-gray-700">En attente</span>
</div>
<span className="font-semibold text-red-600 text-lg">{customerOrderStats.waiting}</span>
</div>
<div className="flex items-center justify-between p-3 hover:bg-gray-50 transition-colors">
<div className="flex items-center space-x-3">
<div className="h-3 w-3 bg-orange-500"></div>
<span className="text-sm font-medium text-gray-700">En cours</span>
</div>
<span className="font-semibold text-orange-600 text-lg">{customerOrderStats.inProgress}</span>
</div>
<div className="flex items-center justify-between p-3 hover:bg-gray-50 transition-colors">
<div className="flex items-center space-x-3">
<div className="h-3 w-3 bg-green-500"></div>
<span className="text-sm font-medium text-gray-700">Disponibles</span>
</div>
<span className="font-semibold text-green-600 text-lg">{customerOrderStats.available}</span>
</div>
<div className="flex items-center justify-between p-3 hover:bg-gray-50 transition-colors">
<div className="flex items-center space-x-3">
<div className="h-3 w-3 bg-blue-500"></div>
<span className="text-sm font-medium text-gray-700">Retirées</span>
</div>
<span className="font-semibold text-blue-600 text-lg">{customerOrderStats.withdrawn}</span>
</div>
<div className="flex items-center justify-between border-t border-gray-200 pt-3 mt-3 p-3">
<div className="flex items-center space-x-3">
<div className="h-3 w-3 bg-gray-500"></div>
<span className="text-sm font-semibold text-gray-800">Total commandes</span>
</div>
<span className="font-bold text-xl text-gray-800">{customerOrderStats.total}</span>
</div>
</CardContent>
</Card>
{/* Système d'informations - Remplace les commandes clients */}
<AnnouncementCard />
{/* Tâches à faire */}
<Card className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow">
@@ -0,0 +1,27 @@
-- Migration: Create announcements table
-- Date: 2025-08-16
-- Description: Create announcements table for admin-managed information system
CREATE TABLE IF NOT EXISTS announcements (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
priority VARCHAR(20) NOT NULL DEFAULT 'normal' CHECK (priority IN ('normal', 'important', 'urgent')),
author_id VARCHAR(255) NOT NULL,
group_id INTEGER NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_announcements_priority ON announcements(priority);
CREATE INDEX IF NOT EXISTS idx_announcements_created_at ON announcements(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_announcements_author_id ON announcements(author_id);
CREATE INDEX IF NOT EXISTS idx_announcements_group_id ON announcements(group_id);
-- Comment on table
COMMENT ON TABLE announcements IS 'Admin-managed announcements/information system';
COMMENT ON COLUMN announcements.priority IS 'Priority level: normal, important, urgent';
COMMENT ON COLUMN announcements.group_id IS 'NULL for global announcements, specific group_id for store-specific announcements';
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env node
/**
* Database Migration Script
* Automatically runs database migrations for production deployment
*/
const { Pool } = require('pg');
const fs = require('fs');
const path = require('path');
// Database connection
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false
});
async function runMigrations() {
console.log('🔄 Starting database migrations...');
try {
// Create migrations table if it doesn't exist
await pool.query(`
CREATE TABLE IF NOT EXISTS migrations (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) UNIQUE NOT NULL,
executed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
`);
console.log('✅ Migrations table ready');
// Get list of executed migrations
const { rows: executedMigrations } = await pool.query(
'SELECT filename FROM migrations ORDER BY executed_at'
);
const executedSet = new Set(executedMigrations.map(row => row.filename));
// Read migration files
const migrationsDir = path.join(__dirname);
const files = fs.readdirSync(migrationsDir)
.filter(file => file.endsWith('.sql'))
.sort();
console.log(`📁 Found ${files.length} migration files`);
let appliedCount = 0;
for (const file of files) {
if (executedSet.has(file)) {
console.log(`⏭️ Skipping ${file} (already executed)`);
continue;
}
console.log(`🔄 Executing migration: ${file}`);
const filePath = path.join(migrationsDir, file);
const sql = fs.readFileSync(filePath, 'utf8');
// Execute migration in a transaction
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(sql);
await client.query(
'INSERT INTO migrations (filename) VALUES ($1)',
[file]
);
await client.query('COMMIT');
console.log(`✅ Successfully executed: ${file}`);
appliedCount++;
} catch (error) {
await client.query('ROLLBACK');
throw new Error(`Failed to execute ${file}: ${error.message}`);
} finally {
client.release();
}
}
if (appliedCount === 0) {
console.log('✅ All migrations are up to date');
} else {
console.log(`✅ Successfully applied ${appliedCount} migration(s)`);
}
} catch (error) {
console.error('❌ Migration failed:', error.message);
process.exit(1);
} finally {
await pool.end();
}
}
// Run migrations if this file is executed directly
if (require.main === module) {
runMigrations();
}
module.exports = { runMigrations };
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env node
/**
* Production deployment script
* Runs database migrations and starts the application
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
async function deployToProduction() {
console.log('🚀 Starting production deployment...');
try {
// Check if we're in production environment
if (process.env.NODE_ENV !== 'production') {
console.log('⚠️ Warning: NODE_ENV is not set to production');
}
// Check if DATABASE_URL is available
if (!process.env.DATABASE_URL) {
console.error('❌ DATABASE_URL environment variable is required for production');
process.exit(1);
}
console.log('✅ Environment variables verified');
// Run database migrations
console.log('🔄 Running database migrations...');
const migrateScriptPath = path.join(__dirname, 'migrations', 'migrate.js');
if (fs.existsSync(migrateScriptPath)) {
execSync(`node ${migrateScriptPath}`, { stdio: 'inherit' });
console.log('✅ Database migrations completed');
} else {
console.log('⚠️ No migration script found, skipping migrations');
}
// Start the application
console.log('🚀 Starting application...');
execSync('npm start', { stdio: 'inherit' });
} catch (error) {
console.error('❌ Deployment failed:', error.message);
process.exit(1);
}
}
// Execute if this script is run directly
if (require.main === module) {
deployToProduction();
}
module.exports = { deployToProduction };
+1
View File
@@ -35,6 +35,7 @@ The platform is built with a clear separation of concerns, utilizing a modern fu
- **Invoice Verification Cache:** Robust caching system for invoice verification results with automatic expiration and conflict resolution. Enhanced with unique constraint handling and graceful error management for production stability (August 2025). Fixed production SSL certificate errors and fetch timeout loops with enhanced error handling, network timeout protection, and error result caching to prevent infinite retry loops (August 2025).
- **UI Scrollbar Optimization:** Eliminated redundant vertical scrollbar on the right side of the screen while preserving necessary internal page scrolling. Implemented global CSS rules with `overflow: hidden` on html/body/root and Layout height management to prevent vertical overflow issues in production (August 2025).
- **Task Management:** Système complet de gestion des tâches avec fonctionnalités temporelles avancées - tri intelligent (pending > priorité > date), dates d'échéance avec sélecteur calendrier, indicateurs visuels pour tâches en retard/urgentes, filtres par échéance (aujourd'hui, cette semaine, en retard, sans échéance), vue Kanban et liste, badges colorés pour statut temporel. Interface optimisée pour suivi prioritaire des tâches non validées. Correction critique de l'isolation des données : chaque magasin ne voit maintenant que ses propres tâches (Août 2025). **PRODUCTION FIX (Août 2025)** : Résolution du problème de rechargements multiples nécessaires pour l'administrateur - le cache des tâches s'invalide maintenant automatiquement lors du changement de magasin sélectionné.
- **Information System (Announcements):** Système d'informations complet remplaçant la carte commandes clients dans le dashboard - gestion d'annonces avec niveaux de priorité (normal, important, urgent), contrôle d'accès basé sur les rôles (admin : CRUD complet, autres rôles : lecture seule), tri intelligent par priorité et date, filtrage par magasin pour administrateurs, interface moderne avec badges colorés et indicateurs visuels. Intégration complète avec migrations automatiques pour déploiement en production (Août 2025).
- **DLC Module Permissions:** Correction des permissions de validation DLC pour permettre aux admin, directeur et manager de valider les produits DLC. Ajout de logs de debug pour traçabilité des validations (Août 2025).
- **User and Permission Management:** Granular access controls ensuring that users (e.g., employees) have appropriate permissions for tasks like creating customer orders.
- **Reporting:** Statistical reporting, including monthly summaries of deliveries based on `deliveredDate`.
+88
View File
@@ -34,6 +34,7 @@ import {
insertDlcProductSchema,
insertDlcProductFrontendSchema,
insertTaskSchema,
insertAnnouncementSchema,
insertNocodbConfigSchema,
insertSavTicketSchema,
insertSavTicketHistorySchema,
@@ -2996,6 +2997,93 @@ RÉSUMÉ DU SCAN
});
// Route de géolocalisation météo
// Announcement routes
app.get('/api/announcements', isAuthenticated, async (req: any, res) => {
try {
const userId = req.user.claims ? req.user.claims.sub : req.user.id;
const user = await storage.getUserWithGroups(userId);
if (!user) {
return res.status(404).json({ message: "User not found" });
}
// Get user's group IDs for filtering
let groupIds: number[] = [];
if (user.role === 'admin') {
// Admin can see all announcements, but can filter by selected store
const storeId = req.query.storeId;
if (storeId) {
groupIds = [parseInt(storeId)];
}
} else {
// Other roles see only their assigned groups + global announcements
const userGroups = (user as any).userGroups;
groupIds = userGroups ? userGroups.map((ug: any) => ug.groupId) : [];
}
const announcements = await storage.getAnnouncements(groupIds.length > 0 ? groupIds : undefined);
res.json(announcements);
} catch (error) {
console.error("Error fetching announcements:", error);
res.status(500).json({ message: "Failed to fetch announcements" });
}
});
app.post('/api/announcements', isAuthenticated, async (req: any, res) => {
try {
const userId = req.user.claims ? req.user.claims.sub : req.user.id;
const user = await storage.getUserWithGroups(userId);
if (!user) {
return res.status(404).json({ message: "User not found" });
}
// Only admin can create announcements
if (user.role !== 'admin') {
return res.status(403).json({ message: "Only administrators can create announcements" });
}
const announcementData = insertAnnouncementSchema.parse({
...req.body,
authorId: user.id,
});
const announcement = await storage.createAnnouncement(announcementData);
res.status(201).json(announcement);
} catch (error) {
console.error("Error creating announcement:", error);
if (error instanceof z.ZodError) {
return res.status(400).json({ message: "Validation error", errors: error.errors });
}
res.status(500).json({ message: "Failed to create announcement" });
}
});
app.delete('/api/announcements/:id', isAuthenticated, async (req: any, res) => {
try {
const userId = req.user.claims ? req.user.claims.sub : req.user.id;
const user = await storage.getUserWithGroups(userId);
if (!user) {
return res.status(404).json({ message: "User not found" });
}
// Only admin can delete announcements
if (user.role !== 'admin') {
return res.status(403).json({ message: "Only administrators can delete announcements" });
}
const id = parseInt(req.params.id);
const success = await storage.deleteAnnouncement(id);
if (!success) {
return res.status(404).json({ message: "Announcement not found" });
}
res.json({ message: "Announcement deleted successfully" });
} catch (error) {
console.error("Error deleting announcement:", error);
res.status(500).json({ message: "Failed to delete announcement" });
}
});
app.post('/api/weather/geolocation', isAuthenticated, async (req: any, res) => {
try {
const user = await storage.getUserWithGroups(req.user.claims ? req.user.claims.sub : req.user.id);
+109
View File
@@ -10,6 +10,7 @@ import {
customerOrders,
dlcProducts,
tasks,
announcements,
nocodbConfig,
invoiceVerificationCache,
savTickets,
@@ -47,6 +48,9 @@ import {
type Task,
type InsertTask,
type TaskWithRelations,
type Announcement,
type InsertAnnouncement,
type AnnouncementWithRelations,
type NocodbConfig,
type InsertNocodbConfig,
type InvoiceVerificationCache,
@@ -170,6 +174,11 @@ export interface IStorage {
updateTask(id: number, task: Partial<InsertTask>): Promise<Task>;
deleteTask(id: number): Promise<void>;
completeTask(id: number, completedBy?: string): Promise<void>;
// Announcement operations
createAnnouncement(announcement: InsertAnnouncement): Promise<Announcement>;
getAnnouncements(groupIds?: number[]): Promise<AnnouncementWithRelations[]>;
deleteAnnouncement(id: number): Promise<boolean>;
// SAV operations
getSavTickets(filters?: {
@@ -1580,6 +1589,55 @@ export class DatabaseStorage implements IStorage {
.where(eq(tasks.id, id));
}
// Announcement operations
async createAnnouncement(announcementData: InsertAnnouncement): Promise<Announcement> {
const [announcement] = await db.insert(announcements).values(announcementData).returning();
return announcement;
}
async getAnnouncements(groupIds?: number[]): Promise<AnnouncementWithRelations[]> {
let query = db
.select({
announcement: announcements,
author: users,
group: groups,
})
.from(announcements)
.leftJoin(users, eq(announcements.authorId, users.id))
.leftJoin(groups, eq(announcements.groupId, groups.id));
// Filter by groupIds if provided (for admin filtering by store)
if (groupIds && groupIds.length > 0) {
query = query.where(
or(
isNull(announcements.groupId), // Global announcements (no group)
inArray(announcements.groupId, groupIds)
)
);
}
const results = await query.orderBy(
// Sort by priority (urgent > important > normal) then by date (newest first)
sql`CASE
WHEN ${announcements.priority} = 'urgent' THEN 3
WHEN ${announcements.priority} = 'important' THEN 2
ELSE 1
END DESC`,
desc(announcements.createdAt)
);
return results.map(result => ({
...result.announcement,
author: result.author!,
group: result.group,
}));
}
async deleteAnnouncement(id: number): Promise<boolean> {
const result = await db.delete(announcements).where(eq(announcements.id, id));
return result.rowCount ? result.rowCount > 0 : false;
}
// SAV operations
async getSavTickets(filters?: {
groupIds?: number[];
@@ -1861,6 +1919,7 @@ export class MemStorage implements IStorage {
private customerOrders = new Map<number, CustomerOrder>();
private dlcProducts = new Map<number, DlcProduct>();
private tasks = new Map<number, Task>();
private announcements = new Map<number, Announcement>();
private savTickets = new Map<number, SavTicket>();
private savTicketHistories = new Map<number, SavTicketHistory[]>();
@@ -1873,6 +1932,7 @@ export class MemStorage implements IStorage {
customerOrder: 1,
dlcProduct: 1,
task: 1,
announcement: 1,
savTicket: 1,
savTicketHistory: 1,
};
@@ -3272,6 +3332,55 @@ export class MemStorage implements IStorage {
// In development, this is a no-op
console.log('🧹 DEV: Weather cache cleared due to location change');
}
// Announcement operations
async createAnnouncement(announcementData: InsertAnnouncement): Promise<Announcement> {
const id = this.idCounters.announcement++;
const announcement: Announcement = {
id,
...announcementData,
createdAt: new Date(),
updatedAt: new Date(),
};
this.announcements.set(id, announcement);
return announcement;
}
async getAnnouncements(groupIds?: number[]): Promise<AnnouncementWithRelations[]> {
const announcements = Array.from(this.announcements.values());
let filteredAnnouncements = announcements;
// Filter by groupIds if provided (for admin filtering by store)
if (groupIds && groupIds.length > 0) {
filteredAnnouncements = announcements.filter(announcement =>
!announcement.groupId || groupIds.includes(announcement.groupId)
);
}
// Add relations
return filteredAnnouncements.map(announcement => {
const author = this.users.get(announcement.authorId);
const group = announcement.groupId ? this.groups.get(announcement.groupId) : undefined;
return {
...announcement,
author: author!,
group,
};
}).sort((a, b) => {
// Sort by priority (urgent > important > normal) then by date (newest first)
const priorityOrder = { urgent: 3, important: 2, normal: 1 };
const priorityDiff = (priorityOrder[b.priority as keyof typeof priorityOrder] || 1) -
(priorityOrder[a.priority as keyof typeof priorityOrder] || 1);
if (priorityDiff !== 0) return priorityDiff;
return new Date(b.createdAt!).getTime() - new Date(a.createdAt!).getTime();
});
}
async deleteAnnouncement(id: number): Promise<boolean> {
return this.announcements.delete(id);
}
}
// Use MemStorage in development, DatabaseStorage in production
+39 -1
View File
@@ -255,6 +255,18 @@ export const tasks = pgTable("tasks", {
updatedAt: timestamp("updated_at").defaultNow(),
});
// Announcements - Système d'information du dashboard
export const announcements = pgTable("announcements", {
id: serial("id").primaryKey(),
title: varchar("title").notNull(), // Titre du message
content: text("content").notNull(), // Contenu du message
priority: varchar("priority").notNull().default("normal"), // normal, important, urgent
authorId: varchar("author_id").notNull(), // Référence vers l'utilisateur créateur
groupId: integer("group_id"), // Filtrage par magasin (null = global)
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
// Relations
export const usersRelations = relations(users, ({ many }) => ({
userGroups: many(userGroups),
@@ -265,6 +277,7 @@ export const usersRelations = relations(users, ({ many }) => ({
createdDlcProducts: many(dlcProducts),
createdTasks: many(tasks),
assignedTasks: many(tasks),
createdAnnouncements: many(announcements),
}));
export const groupsRelations = relations(groups, ({ one, many }) => ({
@@ -275,7 +288,7 @@ export const groupsRelations = relations(groups, ({ one, many }) => ({
customerOrders: many(customerOrders),
dlcProducts: many(dlcProducts),
tasks: many(tasks),
announcements: many(announcements),
}));
export const userGroupsRelations = relations(userGroups, ({ one }) => ({
@@ -396,6 +409,17 @@ export const tasksRelations = relations(tasks, ({ one }) => ({
}),
}));
export const announcementsRelations = relations(announcements, ({ one }) => ({
author: one(users, {
fields: [announcements.authorId],
references: [users.id],
}),
group: one(groups, {
fields: [announcements.groupId],
references: [groups.id],
}),
}));
// Zod schemas
export const insertUserSchema = createInsertSchema(users).pick({
id: true,
@@ -501,6 +525,12 @@ export const insertTaskSchema = createInsertSchema(tasks).omit({
dueDate: z.coerce.date().optional().nullable(), // Convertit automatiquement les chaînes en Date
});
export const insertAnnouncementSchema = createInsertSchema(announcements).omit({
id: true,
createdAt: true,
updatedAt: true,
});
export const insertNocodbConfigSchema = createInsertSchema(nocodbConfig).omit({
id: true,
createdAt: true,
@@ -557,6 +587,8 @@ export type DlcProductFrontend = Omit<DlcProduct, 'expiryDate'> & { dlcDate: Dat
export type InsertDlcProductFrontend = z.infer<typeof insertDlcProductFrontendSchema>;
export type Task = typeof tasks.$inferSelect;
export type InsertTask = z.infer<typeof insertTaskSchema>;
export type Announcement = typeof announcements.$inferSelect;
export type InsertAnnouncement = z.infer<typeof insertAnnouncementSchema>;
// Complex types with relations
@@ -605,6 +637,12 @@ export type TaskWithRelations = Task & {
group: Group;
};
// Announcement with relations type
export type AnnouncementWithRelations = Announcement & {
author: User;
group?: Group;
};
// SAV (Service Après-Vente) Tables
export const savTickets = pgTable("sav_tickets", {
id: serial("id").primaryKey(),