mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
feat(contacts): add birthday and custom dates support
- Add customDates JSONB column to contacts schema - Add CustomDateDto validation in controller - Extend Contact interface with customDates field - Create DateFields.svelte component (collapsible section with birthday + dynamic custom dates) - Integrate DateFields in ContactDetailModal (view + edit mode) - Integrate DateFields in NewContactModal 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
bed7c4e102
commit
1ff172fc8d
6 changed files with 462 additions and 1 deletions
|
|
@ -20,8 +20,22 @@ import {
|
|||
IsDateString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
|
||||
class CustomDateDto {
|
||||
@IsUUID()
|
||||
id: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
label: string;
|
||||
|
||||
@IsDateString()
|
||||
date: string;
|
||||
}
|
||||
|
||||
class CreateContactDto {
|
||||
@IsString()
|
||||
|
|
@ -107,6 +121,12 @@ class CreateContactDto {
|
|||
@IsOptional()
|
||||
notes?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CustomDateDto)
|
||||
customDates?: CustomDateDto[];
|
||||
|
||||
// Social Media
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export const contacts = pgTable('contacts', {
|
|||
birthday: date('birthday'),
|
||||
notes: text('notes'),
|
||||
photoUrl: varchar('photo_url', { length: 500 }),
|
||||
customDates: jsonb('custom_dates').$type<CustomDate[]>().default([]),
|
||||
|
||||
// Social Media
|
||||
linkedin: varchar('linkedin', { length: 255 }),
|
||||
|
|
@ -65,3 +66,9 @@ export const contacts = pgTable('contacts', {
|
|||
|
||||
export type Contact = typeof contacts.$inferSelect;
|
||||
export type NewContact = typeof contacts.$inferInsert;
|
||||
|
||||
export interface CustomDate {
|
||||
id: string;
|
||||
label: string;
|
||||
date: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@ export interface Contact {
|
|||
birthday?: string | null;
|
||||
notes?: string | null;
|
||||
photoUrl?: string | null;
|
||||
customDates?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
date: string;
|
||||
}> | null;
|
||||
// Social Media
|
||||
linkedin?: string | null;
|
||||
twitter?: string | null;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import ContactTasks from './ContactTasks.svelte';
|
||||
import { ContactDetailSkeleton } from '$lib/components/skeletons';
|
||||
import SocialMediaFields from './forms/SocialMediaFields.svelte';
|
||||
import DateFields from './forms/DateFields.svelte';
|
||||
import SocialMediaLinks from './SocialMediaLinks.svelte';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -38,6 +39,10 @@
|
|||
let country = $state('');
|
||||
let notes = $state('');
|
||||
|
||||
// Dates
|
||||
let birthday = $state('');
|
||||
let customDates = $state<Array<{ id: string; label: string; date: string }>>([]);
|
||||
|
||||
// Social Media
|
||||
let linkedin = $state('');
|
||||
let twitter = $state('');
|
||||
|
|
@ -87,6 +92,9 @@
|
|||
postalCode = contact.postalCode || '';
|
||||
country = contact.country || '';
|
||||
notes = contact.notes || '';
|
||||
// Dates
|
||||
birthday = contact.birthday || '';
|
||||
customDates = contact.customDates ? [...contact.customDates] : [];
|
||||
// Social Media
|
||||
linkedin = contact.linkedin || '';
|
||||
twitter = contact.twitter || '';
|
||||
|
|
@ -142,6 +150,9 @@
|
|||
postalCode: postalCode || null,
|
||||
country: country || null,
|
||||
notes: notes || null,
|
||||
// Dates
|
||||
birthday: birthday || null,
|
||||
customDates: customDates.filter((d) => d.label && d.date),
|
||||
// Social Media
|
||||
linkedin: linkedin || null,
|
||||
twitter: twitter || null,
|
||||
|
|
@ -523,6 +534,9 @@
|
|||
<textarea bind:value={notes} rows="4" class="input textarea"></textarea>
|
||||
</section>
|
||||
|
||||
<!-- Dates Section -->
|
||||
<DateFields bind:birthday bind:customDates />
|
||||
|
||||
<!-- Social Media Section -->
|
||||
<SocialMediaFields
|
||||
bind:linkedin
|
||||
|
|
@ -881,6 +895,56 @@
|
|||
</section>
|
||||
{/if}
|
||||
|
||||
{#if contact.birthday || (contact.customDates && contact.customDates.length > 0)}
|
||||
<section class="detail-section">
|
||||
<div class="section-header">
|
||||
<div class="section-icon">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="section-title">Daten</h3>
|
||||
</div>
|
||||
<div class="detail-list">
|
||||
{#if contact.birthday}
|
||||
<div class="detail-item">
|
||||
<div class="detail-content">
|
||||
<span class="detail-label">Geburtstag</span>
|
||||
<span class="detail-value"
|
||||
>{new Date(contact.birthday).toLocaleDateString('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if contact.customDates}
|
||||
{#each contact.customDates as customDate}
|
||||
<div class="detail-item">
|
||||
<div class="detail-content">
|
||||
<span class="detail-label">{customDate.label}</span>
|
||||
<span class="detail-value"
|
||||
>{new Date(customDate.date).toLocaleDateString('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if contact.notes}
|
||||
<section class="detail-section">
|
||||
<div class="section-header">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte';
|
||||
import SocialMediaFields from './forms/SocialMediaFields.svelte';
|
||||
import DateFields from './forms/DateFields.svelte';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
|
|
@ -35,6 +36,10 @@
|
|||
let country = $state('');
|
||||
let notes = $state('');
|
||||
|
||||
// Dates
|
||||
let birthday = $state('');
|
||||
let customDates = $state<Array<{ id: string; label: string; date: string }>>([]);
|
||||
|
||||
// Social Media
|
||||
let linkedin = $state('');
|
||||
let twitter = $state('');
|
||||
|
|
@ -136,6 +141,9 @@
|
|||
postalCode: postalCode || null,
|
||||
country: country || null,
|
||||
notes: notes || null,
|
||||
// Dates
|
||||
birthday: birthday || null,
|
||||
customDates: customDates.filter((d) => d.label && d.date),
|
||||
// Social Media
|
||||
linkedin: linkedin || null,
|
||||
twitter: twitter || null,
|
||||
|
|
@ -533,6 +541,9 @@
|
|||
></textarea>
|
||||
</section>
|
||||
|
||||
<!-- Dates Section -->
|
||||
<DateFields bind:birthday bind:customDates />
|
||||
|
||||
<!-- Social Media Section -->
|
||||
<SocialMediaFields
|
||||
bind:linkedin
|
||||
|
|
|
|||
|
|
@ -0,0 +1,354 @@
|
|||
<script lang="ts">
|
||||
interface CustomDate {
|
||||
id: string;
|
||||
label: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
birthday: string;
|
||||
customDates: CustomDate[];
|
||||
initiallyOpen?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
birthday = $bindable(''),
|
||||
customDates = $bindable([]),
|
||||
initiallyOpen = false,
|
||||
}: Props = $props();
|
||||
|
||||
let isOpen = $state(initiallyOpen);
|
||||
|
||||
// Auto-open if any field has data
|
||||
$effect(() => {
|
||||
if (birthday || customDates.length > 0) {
|
||||
isOpen = true;
|
||||
}
|
||||
});
|
||||
|
||||
function addCustomDate() {
|
||||
customDates = [
|
||||
...customDates,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
label: '',
|
||||
date: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function removeCustomDate(id: string) {
|
||||
customDates = customDates.filter((d) => d.id !== id);
|
||||
}
|
||||
|
||||
function updateCustomDate(id: string, field: 'label' | 'date', value: string) {
|
||||
customDates = customDates.map((d) => (d.id === id ? { ...d, [field]: value } : d));
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="form-section">
|
||||
<button
|
||||
type="button"
|
||||
class="section-header section-header-toggle"
|
||||
onclick={() => (isOpen = !isOpen)}
|
||||
>
|
||||
<div class="section-icon">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="section-title">Daten</h2>
|
||||
<svg
|
||||
class="chevron-icon"
|
||||
class:chevron-open={isOpen}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if isOpen}
|
||||
<div class="dates-container">
|
||||
<!-- Birthday field -->
|
||||
<div class="form-field birthday-field">
|
||||
<label for="birthday" class="label date-label">
|
||||
<span class="date-icon-label">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 15.546c-.523 0-1.046.151-1.5.454a2.704 2.704 0 01-3 0 2.704 2.704 0 00-3 0 2.704 2.704 0 01-3 0 2.704 2.704 0 00-3 0 2.704 2.704 0 01-3 0A2.704 2.704 0 003 15.546V20a1 1 0 001 1h16a1 1 0 001-1v-4.454zM3 15.546V12a2 2 0 012-2h14a2 2 0 012 2v3.546M9 10V4a2 2 0 012-2h2a2 2 0 012 2v6"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
Geburtstag
|
||||
</label>
|
||||
<input id="birthday" type="date" bind:value={birthday} class="input" />
|
||||
</div>
|
||||
|
||||
<!-- Custom dates -->
|
||||
{#each customDates as customDate (customDate.id)}
|
||||
<div class="custom-date-row">
|
||||
<div class="form-field custom-date-label-field">
|
||||
<label for="custom-label-{customDate.id}" class="label">Bezeichnung</label>
|
||||
<input
|
||||
id="custom-label-{customDate.id}"
|
||||
type="text"
|
||||
value={customDate.label}
|
||||
oninput={(e) => updateCustomDate(customDate.id, 'label', e.currentTarget.value)}
|
||||
class="input"
|
||||
placeholder="z.B. Hochzeitstag, Kennenlerndatum"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field custom-date-date-field">
|
||||
<label for="custom-date-{customDate.id}" class="label">Datum</label>
|
||||
<input
|
||||
id="custom-date-{customDate.id}"
|
||||
type="date"
|
||||
value={customDate.date}
|
||||
oninput={(e) => updateCustomDate(customDate.id, 'date', e.currentTarget.value)}
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="remove-button"
|
||||
onclick={() => removeCustomDate(customDate.id)}
|
||||
aria-label="Datum entfernen"
|
||||
>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Add button -->
|
||||
<button type="button" class="add-button" onclick={addCustomDate}>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Datum hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.form-section {
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 1rem;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.section-header-toggle {
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-icon svg {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chevron-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
transition: transform 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chevron-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.dates-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.birthday-field {
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border) / 0.3);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.date-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.date-icon-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.date-icon-label svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
|
||||
.custom-date-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.custom-date-row {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.custom-date-label-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1.5px solid hsl(var(--color-border));
|
||||
border-radius: 0.625rem;
|
||||
background: hsl(var(--color-input));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.9375rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--color-primary));
|
||||
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.1);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: hsl(var(--color-muted-foreground) / 0.6);
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-destructive) / 0.1);
|
||||
color: hsl(var(--color-destructive));
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.remove-button:hover {
|
||||
background: hsl(var(--color-destructive) / 0.2);
|
||||
}
|
||||
|
||||
.remove-button svg {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
|
||||
.add-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1.5px dashed hsl(var(--color-border));
|
||||
border-radius: 0.625rem;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-button:hover {
|
||||
border-color: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.05);
|
||||
}
|
||||
|
||||
.add-button svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue