Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a6414df7a | |||
| 571bf7690d | |||
| 380c9b3647 | |||
| 3cd8d98324 | |||
| 9781cd9ed2 | |||
| 1062b2d96c | |||
| 015906392c | |||
| 5648eafd7c | |||
| b6c6be5c97 | |||
| 5d370a062e | |||
| dfb83d6fd4 |
@@ -6,6 +6,40 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
|
||||
|
||||
## [Yayınlanmamış]
|
||||
|
||||
## [0.8.0] - 2026-11-05
|
||||
|
||||
Faz 8: cari hesaplar ve aynî borç/alacak (8a ve 8b alt fazları).
|
||||
|
||||
### Eklendi — Faz 8b: aynî borç/alacak ve fiyat tablosu
|
||||
|
||||
- Varlık türleri: gram altın, çeyrek, yarım, tam, Cumhuriyet altını, 22 ayar bilezik, gümüş, USD, EUR, GBP (birim: gr / adet / birim; miktarlar binde bir hassasiyetle).
|
||||
- Fiyat tablosu (`/fiyatlar`, `assetPrices`): varlık × tarih × alış/satış, elle giriş; tek fiyat girilirse ikisine de yazılır; gün başına tek kayıt; son fiyatlar ve geçmiş. Faz 9 varlık takibi aynı tabloyu kullanacak.
|
||||
- Aynî borç/alacak (`inKindDebts`), cari sayfasında: cari, yön (ben borçluyum / bana borçlu), varlık, miktar, borç tarihi; o tarihteki birim fiyat ve TL karşılığı not olarak saklanır (boşsa fiyat tablosundan). Değerleme: borç satış, alacak alış fiyatıyla.
|
||||
- Borca bağlı birikim planı: birikim hesabı ve aylık tutar; "Birikim ekle" kaynak hesaptan birikim hesabına transfer oluşturur, borç kapanmaz. Kartta güncel değer, biriken, kalan ihtiyaç ve tahmini kapanış ayı.
|
||||
- "Ödeme gerçekleşti": aynı varlıkla veya TL karşılığıyla; tarih, miktar/tutar, hesap. TL ödemede miktar boşsa güncel fiyattan hesaplanır; kısmi ödeme miktarı düşürür, kalanı aşan ödeme kalana sınırlanır, tam ödemede kayıt kapanır. Borç ödemesi hesaptan çıkış, alacak tahsilatı hesaba giriş işlemi olarak aynı transaction'da oluşturulur; denetim kaydı (`inkind.payment`, `inkind.close`).
|
||||
- Cari listesinde açık aynî kayıtlar rozet olarak.
|
||||
- Aynî borç hareketleri cari TL bakiyesine ve gelir/gider toplamlarına girmez; "yalnızca ben" carinin aynî kayıtları ve hareketleri başkasına görünmez (hesap ekstresinde ad ve açıklama gizlenir). Ödeme işlemi tek başına silinemez.
|
||||
- Seed: son 6 ayın örnek altın/döviz fiyatları (gerçek piyasa verisi değildir), birikim planlı 5 gr altın borcu (iki katkıyla), USD alacağı, Annemler'de çeyrek altın alacağı.
|
||||
- `008` migration'ı: `assetPrices`, `inKindDebts` index'leri, işlemlerde aynî borç alanları.
|
||||
- Testler: değer/miktar karşılığı, değerleme fiyatı, ilerleme ve tahmini kapanış (birim); fiyat tablosu, tarih fiyatı, birikim katkısı, kısmi/tam ödeme, kapanış, alacak tahsilatı, cari bakiye ve gelir/gider ayrımı, gizli cari ve yetki (entegrasyon).
|
||||
|
||||
### Eklendi — Faz 8a: cari hesaplar
|
||||
|
||||
- Cari kişiler (`contacts`): ad, yakınlık, telefon, not, açılış bakiyesi (+ bana borçlu / − ben borçluyum), vade tarihi ve hatırlatma günü; arşivleme; aynı adda iki aktif cari engellenir (Türkçe normalize).
|
||||
- "Yalnızca ben" cari: kişi ve hareketleri yalnızca sahibine görünür; ortak hesabın ekstresinde bu hareketler görünür ama kişi adı "Gizli cari" olarak maskelenir.
|
||||
- Cari hareketleri: "Borç verdim", "Borç ödedim" (hesaptan cariye), "Borç aldım", "Tahsilat" (cariden hesaba). Hepsi transferdir: hesap bakiyesi değişir, gelir/gider toplamlarına girmez. Kısmi ödeme bakiyeyle doğal olarak desteklenir.
|
||||
- `/cari`: bana borçlu / ben borçluyum / net özeti, cari listesi (bakiye, son hareket, vade rozeti), yeni cari, hareket ekle, düzenle, arşivle.
|
||||
- `/cari/:id`: cari ekstresi (tarih sıralı hareketler ve yürüyen bakiye, açılış satırı); hareketi düzenleme ve geri alınabilir silme.
|
||||
- Vade uyarısı (uygulama içi): vade geçtiyse veya hatırlatma penceresindeyse cari listesinde rozet ve dashboard'da uyarı.
|
||||
- İşlem listesi ve ayrıntısında cari hareketleri kişi adı ve hareket türüyle; ayrıntıdan cariye bağlantı. CSV'de kategori sütununda "Cari: Ad (tür)".
|
||||
- Seed: 5 örnek cari (vadesi yaklaşan alacak, kısmen ödenen borç, vadesi geçmiş alacak, "yalnızca ben" cari, Annemler hanesinde bir borç).
|
||||
- `007` migration'ı: `contacts` index'leri, işlemlerde cari alanları. Denetim kaydı: cari arşivleme.
|
||||
- Testler: yön/bakiye ve vade durumu (birim); defter, bakiye ve gelir/gider ayrımı, açılış bakiyesi, ekstre, tür değiştirme, vade uyarısı, çift ad, arşiv, yetki, başka hanenin hesabı ve gizli cari izolasyonu (entegrasyon).
|
||||
|
||||
### Değişti — Faz 8a
|
||||
|
||||
- İşlemlerde kaynak hesap (`accountId`) artık boş olabilir: cariden girişte para yalnızca hedef hesaba (`toAccountId`) girer. Bakiye, ekstre, kart ve KMH hesapları ek kural olmadan doğru çalışır.
|
||||
|
||||
## [0.7.0] - 2026-11-05
|
||||
|
||||
Faz 7: planlı/düzenli işlemler, kredi kartı ve KMH dönemleri, taksitler (7a ve 7b alt fazları).
|
||||
|
||||
@@ -319,6 +319,7 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em
|
||||
- **Canlı domain:** `butcem.ta1sph.website` (Gitea `gitea.ta1sph.info` üzerinde; fark kasıtlı).
|
||||
- **Aktif hane varsayılanı:** tercih (cookie) yoksa kullanıcının en yüksek rolde olduğu hane açılır.
|
||||
- **Faz 7 kararları (2026-11-05):** 7a planlı/düzenli işlemler, 7b kart/KMH dönemleri ve taksitler; `v0.7.0` 7b sonunda. Taksitli harcamada her taksit ayrı işlem ("Hepsiburada 4/9"); vadesi gelen taksit kendi döneminde otomatik gerçekleşir, gelecek taksitler kullanılabilir limitten düşer ama güncel borçta görünmez. Bitiş tarihi olmayan düzenli işlemler 12 ay ileriye üretilir (ufuk okuma sırasında ilerletilir). Asgari ödeme varsayılanı: limit 50.000 TL altı %20, üstü %40; kart bazında değiştirilebilir.
|
||||
- **Faz 8 kararları (2026-11-05):** 8a cari hesaplar, 8b aynî borç/alacak; `v0.8.0` 8b sonunda. Varlık türleri ve elle fiyat tablosu (Bölüm 6.6) 8b'ye çekilir, Faz 9 yeniden kullanır. Cariler hane ortaktır; "yalnızca ben" işaretli cari yalnızca sahibine görünür. Vade hatırlatması uygulama içi uyarıdır (dashboard + cari sayfası); push/e-posta Faz 16'da değerlendirilir.
|
||||
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
|
||||
|
||||
---
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "butcem",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "butcem",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^7.0.2",
|
||||
"@tanstack/react-table": "^9.3.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "butcem",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
|
||||
"engines": {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ContactModel } from "@/models/contact";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
export const migration: Migration = {
|
||||
name: "007-contacts",
|
||||
description: "contacts index'leri; işlemlerde cari alanları ve cari index'i",
|
||||
async up(db) {
|
||||
await db
|
||||
.collection("transactions")
|
||||
.updateMany({ contactKind: { $exists: false } }, { $set: { contactKind: null } });
|
||||
await ContactModel.createIndexes();
|
||||
await TransactionModel.createIndexes();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { AssetPriceModel } from "@/models/asset-price";
|
||||
import { InKindDebtModel } from "@/models/in-kind-debt";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
export const migration: Migration = {
|
||||
name: "008-in-kind",
|
||||
description: "assetPrices ve inKindDebts index'leri; işlemlerde aynî borç alanları",
|
||||
async up(db) {
|
||||
await db
|
||||
.collection("transactions")
|
||||
.updateMany(
|
||||
{ inKindDebtId: { $exists: false } },
|
||||
{ $set: { inKindDebtId: null, inKindRole: null } },
|
||||
);
|
||||
await AssetPriceModel.createIndexes();
|
||||
await InKindDebtModel.createIndexes();
|
||||
},
|
||||
};
|
||||
@@ -6,10 +6,12 @@ import { migration as m003 } from "./003-transactions";
|
||||
import { migration as m004 } from "./004-attachments-reconciliation";
|
||||
import { migration as m005 } from "./005-recurring";
|
||||
import { migration as m006 } from "./006-card-statements";
|
||||
import { migration as m007 } from "./007-contacts";
|
||||
import { migration as m008 } from "./008-in-kind";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
||||
export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006];
|
||||
export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006, m007, m008];
|
||||
|
||||
const COLLECTION = "migrations";
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { Types } from "mongoose";
|
||||
import { createContact, createContactEntry } from "@/features/contacts/service";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { ContactModel } from "@/models/contact";
|
||||
import type { ContactEntryKind, ContactRelation } from "@/shared/contacts";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
import { addDays } from "@/shared/recurrence";
|
||||
import { contactEntrySchema, contactFormSchema } from "@/shared/schemas/contact";
|
||||
|
||||
/**
|
||||
* Örnek cariler (Faz 8a): vadesi yaklaşan alacak, kısmen ödenen borç, vadesi geçmiş küçük alacak
|
||||
* ve "yalnızca ben" cari. İdempotent: cari `seedRef` ile; hareketler yalnızca cari ilk
|
||||
* oluşturulduğunda eklenir.
|
||||
*/
|
||||
type SeedContact = {
|
||||
ref: string;
|
||||
household: string;
|
||||
by: string;
|
||||
name: string;
|
||||
relation: ContactRelation;
|
||||
phone?: string;
|
||||
isPrivate?: boolean;
|
||||
dueInDays?: number;
|
||||
reminderDays?: number;
|
||||
entries: {
|
||||
kind: ContactEntryKind;
|
||||
account: string;
|
||||
amountMinor: number;
|
||||
daysAgo: number;
|
||||
description?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
const CONTACTS: SeedContact[] = [
|
||||
{
|
||||
ref: "ahmet",
|
||||
household: "Çakır Ailesi",
|
||||
by: "admin",
|
||||
name: "Ahmet Yılmaz",
|
||||
relation: "friend",
|
||||
phone: "0532 000 00 01",
|
||||
dueInDays: 5,
|
||||
reminderDays: 7,
|
||||
entries: [
|
||||
{
|
||||
kind: "lend",
|
||||
account: "Ziraat Maaş",
|
||||
amountMinor: 5_000_00,
|
||||
daysAgo: 70,
|
||||
description: "Araba tamiri için",
|
||||
},
|
||||
{ kind: "collect", account: "Cüzdan", amountMinor: 2_000_00, daysAgo: 30 },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: "hasan",
|
||||
household: "Çakır Ailesi",
|
||||
by: "admin",
|
||||
name: "Hasan Çakır",
|
||||
relation: "family",
|
||||
dueInDays: 60,
|
||||
entries: [
|
||||
{
|
||||
kind: "borrow",
|
||||
account: "Ziraat Maaş",
|
||||
amountMinor: 20_000_00,
|
||||
daysAgo: 120,
|
||||
description: "Ev eşyası için",
|
||||
},
|
||||
{ kind: "repay", account: "Ziraat Maaş", amountMinor: 5_000_00, daysAgo: 60 },
|
||||
{ kind: "repay", account: "Ziraat Maaş", amountMinor: 5_000_00, daysAgo: 10 },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: "elif",
|
||||
household: "Çakır Ailesi",
|
||||
by: "ayse",
|
||||
name: "Elif (komşu)",
|
||||
relation: "neighbor",
|
||||
dueInDays: -2,
|
||||
entries: [
|
||||
{
|
||||
kind: "lend",
|
||||
account: "Cüzdan",
|
||||
amountMinor: 300_00,
|
||||
daysAgo: 12,
|
||||
description: "Pazar alışverişi",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: "selin",
|
||||
household: "Çakır Ailesi",
|
||||
by: "ayse",
|
||||
name: "Selin (iş arkadaşı)",
|
||||
relation: "work",
|
||||
isPrivate: true,
|
||||
entries: [{ kind: "lend", account: "Cüzdan", amountMinor: 1_000_00, daysAgo: 8 }],
|
||||
},
|
||||
{
|
||||
ref: "ali",
|
||||
household: "Annemler",
|
||||
by: "fatma",
|
||||
name: "Ali Demir",
|
||||
relation: "family",
|
||||
dueInDays: 45,
|
||||
entries: [{ kind: "borrow", account: "Vakıfbank Emekli", amountMinor: 3_000_00, daysAgo: 25 }],
|
||||
},
|
||||
];
|
||||
|
||||
export async function seedContacts(
|
||||
householdIds: Map<string, Types.ObjectId>,
|
||||
userIds: Map<string, string>,
|
||||
): Promise<number> {
|
||||
const today = todayIstanbul();
|
||||
let created = 0;
|
||||
for (const c of CONTACTS) {
|
||||
const householdId = householdIds.get(c.household)!;
|
||||
const seedRef = `contact:${c.ref}`;
|
||||
if (await ContactModel.exists({ householdId, seedRef })) continue;
|
||||
const by = userIds.get(c.by)!;
|
||||
const oldest = Math.max(...c.entries.map((e) => e.daysAgo));
|
||||
const r = await createContact(
|
||||
by,
|
||||
String(householdId),
|
||||
contactFormSchema.parse({
|
||||
name: c.name,
|
||||
relation: c.relation,
|
||||
phone: c.phone ?? "",
|
||||
openingDate: addDays(today, -oldest - 1),
|
||||
isPrivate: c.isPrivate ?? false,
|
||||
dueDate: c.dueInDays !== undefined ? addDays(today, c.dueInDays) : null,
|
||||
reminderDays: c.reminderDays ?? 3,
|
||||
}),
|
||||
);
|
||||
if (!r.ok) throw new Error(`Seed carisi oluşturulamadı (${c.name}): ${r.error}`);
|
||||
await ContactModel.updateOne({ _id: r.data.id }, { $set: { seedRef } });
|
||||
for (const e of c.entries) {
|
||||
const account = await AccountModel.findOne({ householdId, name: e.account }).lean();
|
||||
const x = await createContactEntry(
|
||||
by,
|
||||
String(householdId),
|
||||
r.data.id,
|
||||
contactEntrySchema.parse({
|
||||
kind: e.kind,
|
||||
accountId: String(account!._id),
|
||||
amount: formatMoneyInput(e.amountMinor),
|
||||
date: addDays(today, -e.daysAgo),
|
||||
description: e.description ?? "",
|
||||
}),
|
||||
);
|
||||
if (!x.ok) throw new Error(`Seed cari hareketi oluşturulamadı (${c.name}): ${x.error}`);
|
||||
}
|
||||
created++;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { Types } from "mongoose";
|
||||
import { addContribution, createInKind, savePrice } from "@/features/in-kind/service";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { AssetPriceModel } from "@/models/asset-price";
|
||||
import { ContactModel } from "@/models/contact";
|
||||
import { InKindDebtModel } from "@/models/in-kind-debt";
|
||||
import type { AssetKey } from "@/shared/assets";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
import { addDays, addMonths } from "@/shared/recurrence";
|
||||
import {
|
||||
assetPriceFormSchema,
|
||||
contributionSchema,
|
||||
inKindFormSchema,
|
||||
} from "@/shared/schemas/in-kind";
|
||||
|
||||
/**
|
||||
* Örnek fiyat tablosu ve aynî kayıtlar (Faz 8b). Fiyatlar örnek amaçlıdır (gerçek piyasa verisi
|
||||
* değildir): son 6 ay, ayda bir kayıt, doğrusal artış. İdempotent.
|
||||
*/
|
||||
|
||||
/** [6 ay önceki alış, bugünkü alış, satış farkı] — birim başına kuruş. */
|
||||
const PRICE_PATH: Partial<Record<AssetKey, [number, number, number]>> = {
|
||||
gram_gold: [4_900_00, 5_600_00, 100_00],
|
||||
quarter_gold: [8_050_00, 9_200_00, 250_00],
|
||||
usd: [40_20, 42_80, 30],
|
||||
eur: [46_90, 49_50, 40],
|
||||
};
|
||||
|
||||
async function seedPrices(householdId: Types.ObjectId, actorId: string, today: string) {
|
||||
for (const [k, [from, to, spread]] of Object.entries(PRICE_PATH)) {
|
||||
const key = k as AssetKey;
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const date = i === 0 ? today : addMonths(today, -i);
|
||||
if (await AssetPriceModel.exists({ householdId, assetKey: key, date })) continue;
|
||||
const buy = Math.round(to - ((to - from) * i) / 6);
|
||||
await savePrice(
|
||||
actorId,
|
||||
String(householdId),
|
||||
assetPriceFormSchema.parse({
|
||||
assetKey: key,
|
||||
date,
|
||||
buy: formatMoneyInput(buy),
|
||||
sell: formatMoneyInput(buy + spread),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type SeedInKind = {
|
||||
ref: string;
|
||||
household: string;
|
||||
contact: string;
|
||||
by: string;
|
||||
direction: "debt" | "receivable";
|
||||
assetKey: AssetKey;
|
||||
quantity: string;
|
||||
daysAgo: number;
|
||||
note?: string;
|
||||
savings?: string;
|
||||
monthlyPlanMinor?: number;
|
||||
contributions?: { from: string; amountMinor: number; daysAgo: number }[];
|
||||
};
|
||||
|
||||
const ITEMS: SeedInKind[] = [
|
||||
{
|
||||
ref: "hasan-altin",
|
||||
household: "Çakır Ailesi",
|
||||
contact: "contact:hasan",
|
||||
by: "admin",
|
||||
direction: "debt",
|
||||
assetKey: "gram_gold",
|
||||
quantity: "5",
|
||||
daysAgo: 95,
|
||||
note: "Düğün için ödünç alınan altın",
|
||||
savings: "Birikim (Vadeli)",
|
||||
monthlyPlanMinor: 4_000_00,
|
||||
contributions: [
|
||||
{ from: "Ziraat Maaş", amountMinor: 4_000_00, daysAgo: 60 },
|
||||
{ from: "Ziraat Maaş", amountMinor: 4_000_00, daysAgo: 30 },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: "elif-usd",
|
||||
household: "Çakır Ailesi",
|
||||
contact: "contact:elif",
|
||||
by: "ayse",
|
||||
direction: "receivable",
|
||||
assetKey: "usd",
|
||||
quantity: "100",
|
||||
daysAgo: 40,
|
||||
},
|
||||
{
|
||||
ref: "ali-ceyrek",
|
||||
household: "Annemler",
|
||||
contact: "contact:ali",
|
||||
by: "fatma",
|
||||
direction: "receivable",
|
||||
assetKey: "quarter_gold",
|
||||
quantity: "2",
|
||||
daysAgo: 150,
|
||||
note: "Torun doğumunda verilen çeyrekler",
|
||||
},
|
||||
];
|
||||
|
||||
export async function seedInKind(
|
||||
householdIds: Map<string, Types.ObjectId>,
|
||||
userIds: Map<string, string>,
|
||||
): Promise<number> {
|
||||
const today = todayIstanbul();
|
||||
await seedPrices(householdIds.get("Çakır Ailesi")!, userIds.get("admin")!, today);
|
||||
await seedPrices(householdIds.get("Annemler")!, userIds.get("fatma")!, today);
|
||||
let created = 0;
|
||||
for (const x of ITEMS) {
|
||||
const householdId = householdIds.get(x.household)!;
|
||||
const seedRef = `inkind:${x.ref}`;
|
||||
if (await InKindDebtModel.exists({ householdId, seedRef })) continue;
|
||||
const contact = await ContactModel.findOne({ householdId, seedRef: x.contact }).lean();
|
||||
if (!contact) continue;
|
||||
const account = async (name: string) =>
|
||||
String((await AccountModel.findOne({ householdId, name }).lean())!._id);
|
||||
const by = userIds.get(x.by)!;
|
||||
const r = await createInKind(
|
||||
by,
|
||||
String(householdId),
|
||||
String(contact._id),
|
||||
inKindFormSchema.parse({
|
||||
direction: x.direction,
|
||||
assetKey: x.assetKey,
|
||||
quantity: x.quantity,
|
||||
date: addDays(today, -x.daysAgo),
|
||||
note: x.note ?? "",
|
||||
savingsAccountId: x.savings ? await account(x.savings) : "",
|
||||
monthlyPlan: x.monthlyPlanMinor ? formatMoneyInput(x.monthlyPlanMinor) : "",
|
||||
}),
|
||||
);
|
||||
if (!r.ok) throw new Error(`Seed aynî kaydı oluşturulamadı (${x.ref}): ${r.error}`);
|
||||
await InKindDebtModel.updateOne({ _id: r.data.id }, { $set: { seedRef } });
|
||||
for (const c of x.contributions ?? []) {
|
||||
const res = await addContribution(
|
||||
by,
|
||||
String(householdId),
|
||||
r.data.id,
|
||||
contributionSchema.parse({
|
||||
fromAccountId: await account(c.from),
|
||||
amount: formatMoneyInput(c.amountMinor),
|
||||
date: addDays(today, -c.daysAgo),
|
||||
}),
|
||||
);
|
||||
if (!res.ok) throw new Error(`Seed birikimi oluşturulamadı (${x.ref}): ${res.error}`);
|
||||
}
|
||||
created++;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
@@ -47,6 +47,8 @@ describe("seed", () => {
|
||||
transactions: 0,
|
||||
recurringRules: 0,
|
||||
installmentPlans: 0,
|
||||
contacts: 0,
|
||||
inKind: 0,
|
||||
});
|
||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
|
||||
|
||||
@@ -10,6 +10,8 @@ import { HouseholdModel } from "@/models/household";
|
||||
import { HouseholdMemberModel } from "@/models/household-member";
|
||||
import { storedEmail } from "@/shared/account-email";
|
||||
import { runMigrations } from "../migrations";
|
||||
import { seedContacts } from "./contacts";
|
||||
import { seedInKind } from "./in-kind";
|
||||
import { seedInstallments } from "./installments";
|
||||
import { seedRecurring } from "./recurring";
|
||||
import { seedTransactions } from "./transactions";
|
||||
@@ -47,6 +49,8 @@ export type SeedSummary = {
|
||||
transactions: number;
|
||||
recurringRules: number;
|
||||
installmentPlans: number;
|
||||
contacts: number;
|
||||
inKind: number;
|
||||
};
|
||||
accounts: SeedAccountRow[];
|
||||
};
|
||||
@@ -64,6 +68,9 @@ const DOMAIN_COLLECTIONS = [
|
||||
"reconciliations",
|
||||
"recurringRules",
|
||||
"statements",
|
||||
"contacts",
|
||||
"assetPrices",
|
||||
"inKindDebts",
|
||||
];
|
||||
|
||||
type AuthApi = ReturnType<typeof getAuth>["api"];
|
||||
@@ -115,6 +122,8 @@ export async function runSeed({
|
||||
transactions: 0,
|
||||
recurringRules: 0,
|
||||
installmentPlans: 0,
|
||||
contacts: 0,
|
||||
inKind: 0,
|
||||
},
|
||||
accounts: [],
|
||||
};
|
||||
@@ -289,6 +298,10 @@ export async function runSeed({
|
||||
);
|
||||
if (summary.created.installmentPlans)
|
||||
log(`✓ Örnek taksitli alışverişler: ${summary.created.installmentPlans}`);
|
||||
summary.created.contacts = await seedContacts(householdIds, userIdMap);
|
||||
if (summary.created.contacts) log(`✓ Örnek cariler: ${summary.created.contacts}`);
|
||||
summary.created.inKind = await seedInKind(householdIds, userIdMap);
|
||||
if (summary.created.inKind) log(`✓ Örnek aynî kayıtlar ve fiyatlar: ${summary.created.inKind}`);
|
||||
|
||||
// 7) Geliştirme hesapları tablosu
|
||||
const all = [
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ContactStatementView } from "@/features/contacts/components/contact-statement-view";
|
||||
import { getContactStatement } from "@/features/contacts/service";
|
||||
import { listInKind } from "@/features/in-kind/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.contacts.statement };
|
||||
|
||||
/** /cari/:id — cari ekstresi (gizli cari yalnızca sahibine; aksi 404). */
|
||||
export default async function ContactPage({ params }: PageProps<"/cari/[id]">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const { id } = await params;
|
||||
const [data, inKind] = await Promise.all([
|
||||
getContactStatement(user.id, household.id, id),
|
||||
listInKind(user.id, household.id, id),
|
||||
]);
|
||||
if (!data) notFound();
|
||||
return <ContactStatementView key={id} data={data} inKind={inKind ?? []} />;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { ContactsView } from "@/features/contacts/components/contacts-view";
|
||||
import { listContacts } from "@/features/contacts/service";
|
||||
import { openInKindByContact } from "@/features/in-kind/service";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.contacts.title };
|
||||
|
||||
export default async function ContactsPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const [data, inKind] = await Promise.all([
|
||||
listContacts(user.id, household.id),
|
||||
openInKindByContact(user.id, household.id),
|
||||
]);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.contacts.title} description={tr.contacts.description} />
|
||||
<ContactsView
|
||||
data={data}
|
||||
inKind={Object.fromEntries(inKind)}
|
||||
canWrite={hasHouseholdRole(household.role, "member")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Suspense } from "react";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
|
||||
import { ContactsDueBanner } from "@/features/contacts/components/due-banner";
|
||||
import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
@@ -63,6 +64,9 @@ export default async function DashboardPage() {
|
||||
<Suspense fallback={null}>
|
||||
<OverdueBanner userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<ContactsDueBanner userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
<Suspense fallback={<SummaryCardsSkeleton />}>
|
||||
<SummaryCards />
|
||||
</Suspense>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { PricesView } from "@/features/in-kind/components/prices-view";
|
||||
import { listPrices } from "@/features/in-kind/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.prices.title };
|
||||
|
||||
/** /fiyatlar — altın ve döviz fiyat tablosu (elle; Faz 9 varlık takibi de kullanır). */
|
||||
export default async function PricesPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const data = await listPrices(user.id, household.id);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.prices.title} description={tr.prices.description} />
|
||||
<PricesView rows={data.rows} canWrite={data.canWrite} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export type NavItem = {
|
||||
|
||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
||||
export const CURRENT_PHASE = 7.2;
|
||||
export const CURRENT_PHASE = 8.2;
|
||||
|
||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||
|
||||
@@ -37,7 +37,7 @@ export const MAIN_NAV: NavItem[] = [
|
||||
{ href: "/hesaplar", label: tr.nav.accounts, icon: Wallet, phase: 6.1 },
|
||||
{ href: "/kategoriler", label: tr.nav.categories, icon: Tags, phase: 6.1 },
|
||||
{ href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7.1 },
|
||||
{ href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8 },
|
||||
{ href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8.1 },
|
||||
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9 },
|
||||
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 },
|
||||
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11 },
|
||||
|
||||
@@ -245,7 +245,7 @@ describe("işleme bağlama", () => {
|
||||
cakir,
|
||||
txId,
|
||||
await form({
|
||||
accountId: detail!.account.id,
|
||||
accountId: detail!.account!.id,
|
||||
categoryId: detail!.categoryId,
|
||||
attachmentIds: [a3.id, a4.id],
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, type ActionResult } from "@/lib/action-result";
|
||||
import { getHouseholdActionContext } from "@/lib/dal";
|
||||
import { contactEntrySchema, contactFormSchema } from "@/shared/schemas/contact";
|
||||
import {
|
||||
createContact,
|
||||
createContactEntry,
|
||||
setContactArchived,
|
||||
updateContact,
|
||||
updateContactEntry,
|
||||
} from "./service";
|
||||
|
||||
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
|
||||
|
||||
function fieldErrors(error: z.ZodError) {
|
||||
const out: Record<string, string> = {};
|
||||
for (const issue of error.issues) {
|
||||
const key = issue.path.join(".");
|
||||
if (key && !out[key]) out[key] = issue.message;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function revalidate() {
|
||||
revalidatePath("/", "layout");
|
||||
}
|
||||
|
||||
export async function saveContactAction(
|
||||
contactId: unknown,
|
||||
input: unknown,
|
||||
): Promise<ActionResult<unknown>> {
|
||||
const ctx = await getHouseholdActionContext("member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const parsed = contactFormSchema.safeParse(input);
|
||||
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||
let result: ActionResult<unknown>;
|
||||
if (contactId === null || contactId === undefined) {
|
||||
result = await createContact(ctx.user.id, ctx.household.id, parsed.data);
|
||||
} else {
|
||||
const id = idSchema.safeParse(contactId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
result = await updateContact(ctx.user.id, ctx.household.id, id.data, parsed.data);
|
||||
}
|
||||
if (result.ok) revalidate();
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function setContactArchivedAction(
|
||||
contactId: unknown,
|
||||
archived: unknown,
|
||||
): Promise<ActionResult> {
|
||||
const ctx = await getHouseholdActionContext("member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const id = idSchema.safeParse(contactId);
|
||||
const a = z.boolean().safeParse(archived);
|
||||
if (!id.success || !a.success) return fail(tr.common.notFound);
|
||||
const result = await setContactArchived(ctx.user.id, ctx.household.id, id.data, a.data);
|
||||
if (result.ok) revalidate();
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Yeni hareket (transactionId boş) veya mevcut hareketi düzenle. */
|
||||
export async function saveContactEntryAction(
|
||||
contactId: unknown,
|
||||
transactionId: unknown,
|
||||
input: unknown,
|
||||
): Promise<ActionResult<unknown>> {
|
||||
const ctx = await getHouseholdActionContext("member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const parsed = contactEntrySchema.safeParse(input);
|
||||
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||
let result: ActionResult<unknown>;
|
||||
if (transactionId) {
|
||||
const tid = idSchema.safeParse(transactionId);
|
||||
if (!tid.success) return fail(tr.common.notFound);
|
||||
result = await updateContactEntry(ctx.user.id, ctx.household.id, tid.data, parsed.data);
|
||||
} else {
|
||||
const cid = idSchema.safeParse(contactId);
|
||||
if (!cid.success) return fail(tr.common.notFound);
|
||||
result = await createContactEntry(ctx.user.id, ctx.household.id, cid.data, parsed.data);
|
||||
}
|
||||
if (result.ok) revalidate();
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { MoneyInput } from "@/components/money-input";
|
||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
getTransactionFormOptionsAction,
|
||||
setTransactionDeletedAction,
|
||||
} from "@/features/transactions/actions";
|
||||
import { AccountSelect } from "@/features/transactions/components/transaction-form";
|
||||
import type { TransactionFormOptions, TransactionListItem } from "@/features/transactions/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import {
|
||||
CONTACT_ENTRY_KINDS,
|
||||
CONTACT_RELATIONS,
|
||||
isOutgoing,
|
||||
type ContactEntryKind,
|
||||
type ContactRelation,
|
||||
} from "@/shared/contacts";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
import { saveContactAction, saveContactEntryAction } from "../actions";
|
||||
import type { ContactView } from "../service";
|
||||
|
||||
const t = tr.contacts;
|
||||
type Errors = Record<string, string | undefined>;
|
||||
|
||||
/** Cari oluştur / düzenle. */
|
||||
export function ContactFormDialog({
|
||||
open,
|
||||
contact,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
contact: ContactView | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
return open ? (
|
||||
<ContactForm key={contact?.id ?? "new"} contact={contact} onClose={() => onOpenChange(false)} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
function ContactForm({ contact, onClose }: { contact: ContactView | null; onClose: () => void }) {
|
||||
const [v, setV] = useState({
|
||||
name: contact?.name ?? "",
|
||||
relation: (contact?.relation ?? "friend") as ContactRelation,
|
||||
phone: contact?.phone ?? "",
|
||||
note: contact?.note ?? "",
|
||||
openingBalance: contact?.openingBalanceMinor
|
||||
? formatMoneyInput(contact.openingBalanceMinor)
|
||||
: "",
|
||||
openingDate: contact?.openingDate ?? todayIstanbul(),
|
||||
dueDate: contact?.dueDate ?? "",
|
||||
reminderDays: String(contact?.reminderDays ?? 3),
|
||||
isPrivate: contact?.isPrivate ?? false,
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const set = <K extends keyof typeof v>(k: K, value: (typeof v)[K]) =>
|
||||
setV((x) => ({ ...x, [k]: value }));
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await saveContactAction(contact?.id ?? null, v);
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(contact ? t.toast.updated : t.toast.created);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
const f = t.fields;
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={contact ? t.editTitle : t.createTitle}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form="contact-form" disabled={busy}>
|
||||
{busy ? <Loader2 className="animate-spin" /> : null}
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="contact-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={Boolean(errors.name)}>
|
||||
<FieldLabel htmlFor="ct-name">{f.name}</FieldLabel>
|
||||
<Input
|
||||
id="ct-name"
|
||||
value={v.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
autoFocus
|
||||
/>
|
||||
{errors.name ? <FieldError>{errors.name}</FieldError> : null}
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ct-relation">{f.relation}</FieldLabel>
|
||||
<Select
|
||||
value={v.relation}
|
||||
onValueChange={(x) => set("relation", x as ContactRelation)}
|
||||
>
|
||||
<SelectTrigger id="ct-relation" className="w-full">
|
||||
<SelectValue>{t.relations[v.relation]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONTACT_RELATIONS.map((r) => (
|
||||
<SelectItem key={r} value={r}>
|
||||
{t.relations[r]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.phone)}>
|
||||
<FieldLabel htmlFor="ct-phone">{f.phone}</FieldLabel>
|
||||
<Input
|
||||
id="ct-phone"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
value={v.phone}
|
||||
onChange={(e) => set("phone", e.target.value)}
|
||||
aria-invalid={Boolean(errors.phone)}
|
||||
/>
|
||||
{errors.phone ? <FieldError>{errors.phone}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.openingBalance)}>
|
||||
<FieldLabel htmlFor="ct-opening">{f.openingBalance}</FieldLabel>
|
||||
<MoneyInput
|
||||
id="ct-opening"
|
||||
value={v.openingBalance}
|
||||
onChange={(x) => set("openingBalance", x)}
|
||||
allowNegative
|
||||
/>
|
||||
{errors.openingBalance ? <FieldError>{errors.openingBalance}</FieldError> : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ct-opening-date">{tr.transactions.fields.date}</FieldLabel>
|
||||
<Input
|
||||
id="ct-opening-date"
|
||||
type="date"
|
||||
value={v.openingDate}
|
||||
onChange={(e) => set("openingDate", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<FieldDescription className="-mt-2">{f.openingHint}</FieldDescription>
|
||||
<DueFields
|
||||
dueDate={v.dueDate}
|
||||
reminderDays={v.reminderDays}
|
||||
onChange={(d, r) => setV((x) => ({ ...x, dueDate: d, reminderDays: r }))}
|
||||
errors={errors}
|
||||
/>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ct-note">{f.note}</FieldLabel>
|
||||
<Textarea
|
||||
id="ct-note"
|
||||
rows={2}
|
||||
value={v.note}
|
||||
onChange={(e) => set("note", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={v.isPrivate}
|
||||
onCheckedChange={(x) => set("isPrivate", x === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
{f.isPrivate}
|
||||
</label>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DueFields({
|
||||
dueDate,
|
||||
reminderDays,
|
||||
onChange,
|
||||
errors,
|
||||
}: {
|
||||
dueDate: string;
|
||||
reminderDays: string;
|
||||
onChange: (dueDate: string, reminderDays: string) => void;
|
||||
errors: Errors;
|
||||
}) {
|
||||
const f = t.fields;
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.dueDate)}>
|
||||
<FieldLabel htmlFor="ct-due">{f.dueDate}</FieldLabel>
|
||||
<Input
|
||||
id="ct-due"
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => onChange(e.target.value, reminderDays)}
|
||||
aria-describedby="ct-due-hint"
|
||||
/>
|
||||
<FieldDescription id="ct-due-hint">{f.dueHint}</FieldDescription>
|
||||
{errors.dueDate ? <FieldError>{errors.dueDate}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.reminderDays)}>
|
||||
<FieldLabel htmlFor="ct-remind">{f.reminderDays}</FieldLabel>
|
||||
<Input
|
||||
id="ct-remind"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={60}
|
||||
value={reminderDays}
|
||||
onChange={(e) => onChange(dueDate, e.target.value)}
|
||||
disabled={!dueDate}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type EntryMode = {
|
||||
contact: ContactView;
|
||||
entry?: TransactionListItem;
|
||||
kind?: ContactEntryKind;
|
||||
};
|
||||
|
||||
/** Cari hareketi ekle / düzenle (düzenlemede sil). */
|
||||
export function ContactEntryDialog({
|
||||
mode,
|
||||
onClose,
|
||||
}: {
|
||||
mode: EntryMode | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return mode ? (
|
||||
<EntryForm key={mode.entry?.id ?? `new-${mode.kind ?? ""}`} mode={mode} onClose={onClose} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
function EntryForm({ mode, onClose }: { mode: EntryMode; onClose: () => void }) {
|
||||
const { contact, entry } = mode;
|
||||
const [options, setOptions] = useState<TransactionFormOptions | null>(null);
|
||||
const [kind, setKind] = useState<ContactEntryKind>(
|
||||
entry?.contact?.kind ?? mode.kind ?? (contact.balanceMinor < 0 ? "repay" : "lend"),
|
||||
);
|
||||
const [v, setV] = useState({
|
||||
accountId: entry?.account?.id ?? entry?.toAccount?.id ?? "",
|
||||
amount: entry ? formatMoneyInput(entry.amountMinor) : "",
|
||||
date: entry?.date ?? todayIstanbul(),
|
||||
description: entry?.description ?? "",
|
||||
dueDate: contact.dueDate ?? "",
|
||||
reminderDays: String(contact.reminderDays),
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getTransactionFormOptionsAction().then((r) => {
|
||||
if (cancelled || !r.ok) return;
|
||||
setOptions(r.data);
|
||||
setV((x) => (x.accountId ? x : { ...x, accountId: r.data.accounts[0]?.id ?? "" }));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const accounts = (() => {
|
||||
const list = [...(options?.accounts ?? [])];
|
||||
const current = entry?.account ?? entry?.toAccount;
|
||||
if (current && !list.some((a) => a.id === current.id))
|
||||
list.push({ ...current, type: "bank", icon: "wallet", color: "slate" });
|
||||
return list;
|
||||
})();
|
||||
const createsDebt = kind === "lend" || kind === "borrow";
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const payload = {
|
||||
kind,
|
||||
accountId: v.accountId,
|
||||
amount: v.amount,
|
||||
date: v.date,
|
||||
description: v.description,
|
||||
// Vade yalnızca borç veren/alan harekette güncellenir.
|
||||
...(createsDebt ? { dueDate: v.dueDate, reminderDays: v.reminderDays } : {}),
|
||||
};
|
||||
const r = await saveContactEntryAction(contact.id, entry?.id ?? null, payload);
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.toast.entrySaved);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!entry) return;
|
||||
const r = await setTransactionDeletedAction(entry.id, true);
|
||||
if (!r.ok) return void toast.error(r.error);
|
||||
onClose();
|
||||
toast.success(tr.transactions.toast.deleted, {
|
||||
duration: 10_000,
|
||||
action: {
|
||||
label: tr.transactions.actions.restore,
|
||||
onClick: async () => {
|
||||
const x = await setTransactionDeletedAction(entry.id, false);
|
||||
if (x.ok) toast.success(tr.transactions.toast.restored);
|
||||
else toast.error(x.error);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const f = t.fields;
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={`${entry ? t.editEntryTitle : t.entryTitle} · ${contact.name}`}
|
||||
footer={
|
||||
<>
|
||||
{entry ? (
|
||||
<Button variant="destructive" type="button" onClick={remove} className="sm:mr-auto">
|
||||
<Trash2 />
|
||||
{tr.transactions.actions.delete}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form="contact-entry-form" disabled={busy || !options}>
|
||||
{busy ? <Loader2 className="animate-spin" /> : null}
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="contact-entry-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Tabs value={kind} onValueChange={(x) => setKind(x as ContactEntryKind)}>
|
||||
<TabsList className="grid h-auto w-full grid-cols-2 sm:grid-cols-4">
|
||||
{CONTACT_ENTRY_KINDS.map((k) => (
|
||||
<TabsTrigger key={k} value={k}>
|
||||
{t.kinds[k]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<p className="-mt-2 text-xs text-muted-foreground">{t.kindHints[kind]}</p>
|
||||
<div className="grid grid-cols-[1fr_auto] gap-3">
|
||||
<Field data-invalid={Boolean(errors.amount)}>
|
||||
<FieldLabel htmlFor="ce-amount">{tr.transactions.fields.amount}</FieldLabel>
|
||||
<MoneyInput
|
||||
id="ce-amount"
|
||||
value={v.amount}
|
||||
onChange={(x) => setV((s) => ({ ...s, amount: x }))}
|
||||
aria-invalid={Boolean(errors.amount)}
|
||||
autoFocus
|
||||
/>
|
||||
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.date)}>
|
||||
<FieldLabel htmlFor="ce-date">{tr.transactions.fields.date}</FieldLabel>
|
||||
<Input
|
||||
id="ce-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
onChange={(e) => setV((s) => ({ ...s, date: e.target.value }))}
|
||||
/>
|
||||
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<Field data-invalid={Boolean(errors.accountId)}>
|
||||
<FieldLabel htmlFor="ce-account">
|
||||
{isOutgoing(kind) ? f.fromAccount : f.toAccount}
|
||||
</FieldLabel>
|
||||
<AccountSelect
|
||||
id="ce-account"
|
||||
value={v.accountId}
|
||||
onChange={(x) => setV((s) => ({ ...s, accountId: x }))}
|
||||
accounts={accounts}
|
||||
invalid={Boolean(errors.accountId)}
|
||||
/>
|
||||
{errors.accountId ? <FieldError>{errors.accountId}</FieldError> : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ce-description">{tr.transactions.fields.description}</FieldLabel>
|
||||
<Input
|
||||
id="ce-description"
|
||||
value={v.description}
|
||||
onChange={(e) => setV((s) => ({ ...s, description: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
{createsDebt ? (
|
||||
<DueFields
|
||||
dueDate={v.dueDate}
|
||||
reminderDays={v.reminderDays}
|
||||
onChange={(d, r) => setV((s) => ({ ...s, dueDate: d, reminderDays: r }))}
|
||||
errors={errors}
|
||||
/>
|
||||
) : null}
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Lock, Pencil, Phone, Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDate, formatMoney } from "@/shared/format";
|
||||
import { InKindSection } from "@/features/in-kind/components/in-kind-section";
|
||||
import type { InKindView } from "@/features/in-kind/service";
|
||||
import type { ContactStatement } from "../service";
|
||||
import { ContactEntryDialog, ContactFormDialog, type EntryMode } from "./contact-dialogs";
|
||||
import { ContactBalance, DueBadge } from "./contacts-view";
|
||||
|
||||
const t = tr.contacts;
|
||||
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||
|
||||
function Delta({ minor }: { minor: number }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"font-semibold whitespace-nowrap tabular-nums",
|
||||
minor > 0 ? "text-income" : "text-expense",
|
||||
)}
|
||||
>
|
||||
{minor > 0 ? "+" : "−"}
|
||||
{formatMoney(Math.abs(minor))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Cari ekstresi: hareketler (yeniden eskiye) ve yürüyen bakiye (+ bana borçlu, − ben borçluyum). */
|
||||
export function ContactStatementView({
|
||||
data,
|
||||
inKind,
|
||||
}: {
|
||||
data: ContactStatement;
|
||||
inKind: InKindView[];
|
||||
}) {
|
||||
const c = data.contact;
|
||||
const [entry, setEntry] = useState<EntryMode | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const canWrite = data.canWrite && !c.archived;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||
<Link href="/cari">
|
||||
<ArrowLeft />
|
||||
{t.title}
|
||||
</Link>
|
||||
</Button>
|
||||
<PageHeader
|
||||
title={c.name}
|
||||
description={t.relations[c.relation]}
|
||||
actions={
|
||||
data.canWrite ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setEditing(true)}>
|
||||
<Pencil />
|
||||
<span className="hidden sm:inline">{tr.common.edit}</span>
|
||||
</Button>
|
||||
{canWrite ? (
|
||||
<Button onClick={() => setEntry({ contact: c })}>
|
||||
<Plus />
|
||||
{t.newEntry}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Card>
|
||||
<CardContent className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<DueBadge contact={c} />
|
||||
{c.phone ? (
|
||||
<a
|
||||
href={`tel:${c.phone}`}
|
||||
className="inline-flex items-center gap-1.5 hover:underline"
|
||||
>
|
||||
<Phone className="size-3.5" aria-hidden />
|
||||
{c.phone}
|
||||
</a>
|
||||
) : null}
|
||||
{c.isPrivate ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Lock className="size-3.5" aria-hidden />
|
||||
{t.privateBadge}
|
||||
</span>
|
||||
) : null}
|
||||
{c.note ? <p className="text-muted-foreground">{c.note}</p> : null}
|
||||
</div>
|
||||
<ContactBalance minor={c.balanceMinor} className="text-lg" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<InKindSection contactId={c.id} items={inKind} canWrite={canWrite} />
|
||||
|
||||
<h2 className="text-base font-medium">{t.statement}</h2>
|
||||
{data.rows.length === 0 && c.openingBalanceMinor === 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>{t.emptyEntries}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
<ul className="divide-y rounded-xl border bg-card">
|
||||
{data.rows.map((r) => {
|
||||
const content = (
|
||||
<>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">
|
||||
{tr.contacts.kinds[r.item.contact!.kind]}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{d(r.item.date)} · {r.item.account?.name ?? r.item.toAccount?.name ?? "—"}
|
||||
{r.item.description ? ` · ${r.item.description}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex flex-col items-end">
|
||||
<Delta minor={r.deltaMinor} />
|
||||
<span
|
||||
className="text-xs text-muted-foreground tabular-nums"
|
||||
title={t.runningBalance}
|
||||
>
|
||||
{formatMoney(r.balanceMinor)}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
const cls = "flex w-full items-center gap-3 p-3 text-left";
|
||||
return (
|
||||
<li key={r.item.id}>
|
||||
{canWrite ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(cls, "outline-none hover:bg-muted/50 focus-visible:bg-muted")}
|
||||
onClick={() => setEntry({ contact: c, entry: r.item })}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div className={cls}>{content}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
<li className="flex items-center gap-3 p-3 text-sm text-muted-foreground">
|
||||
<span className="flex-1">
|
||||
{t.opening} · {d(c.openingDate)}
|
||||
</span>
|
||||
<span className="tabular-nums">{formatMoney(c.openingBalanceMinor)}</span>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<ContactEntryDialog mode={entry} onClose={() => setEntry(null)} />
|
||||
<ContactFormDialog open={editing} contact={c} onOpenChange={setEditing} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
ChevronDown,
|
||||
EllipsisVertical,
|
||||
Lock,
|
||||
Pencil,
|
||||
Plus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDate, formatMoney } from "@/shared/format";
|
||||
import { setContactArchivedAction } from "../actions";
|
||||
import type { ContactList, ContactView } from "../service";
|
||||
import { ContactEntryDialog, ContactFormDialog, type EntryMode } from "./contact-dialogs";
|
||||
|
||||
const t = tr.contacts;
|
||||
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||
|
||||
export function ContactBalance({ minor, className }: { minor: number; className?: string }) {
|
||||
const label = minor > 0 ? t.balance.owesMe : minor < 0 ? t.balance.iOwe : t.balance.settled;
|
||||
return (
|
||||
<span className={cn("flex flex-col items-end", className)}>
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-semibold whitespace-nowrap tabular-nums",
|
||||
minor > 0 && "text-income",
|
||||
minor < 0 && "text-expense",
|
||||
)}
|
||||
>
|
||||
{formatMoney(Math.abs(minor))}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function DueBadge({ contact }: { contact: ContactView }) {
|
||||
if (!contact.dueDate || contact.balanceMinor === 0) return null;
|
||||
if (contact.due === "overdue")
|
||||
return <Badge variant="destructive">{t.due.overdue(d(contact.dueDate))}</Badge>;
|
||||
if (contact.due === "upcoming")
|
||||
return <Badge variant="secondary">{t.due.upcoming(d(contact.dueDate))}</Badge>;
|
||||
return <Badge variant="outline">{t.due.on(d(contact.dueDate))}</Badge>;
|
||||
}
|
||||
|
||||
export function ContactsView({
|
||||
data,
|
||||
inKind,
|
||||
canWrite,
|
||||
}: {
|
||||
data: ContactList;
|
||||
/** Açık aynî kayıtlar (cari kimliği → yön ve kalan miktar). */
|
||||
inKind: Record<string, { direction: "debt" | "receivable"; label: string }[]>;
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const [form, setForm] = useState<{ contact: ContactView | null } | null>(null);
|
||||
const [entry, setEntry] = useState<EntryMode | null>(null);
|
||||
const [archivedOpen, setArchivedOpen] = useState(false);
|
||||
const [, startTransition] = useTransition();
|
||||
const active = data.contacts.filter((c) => !c.archived);
|
||||
const archived = data.contacts.filter((c) => c.archived);
|
||||
|
||||
function archive(c: ContactView, value: boolean) {
|
||||
startTransition(async () => {
|
||||
const r = await setContactArchivedAction(c.id, value);
|
||||
if (r.ok) toast.success(value ? t.toast.archived : t.toast.unarchived);
|
||||
else toast.error(r.error);
|
||||
});
|
||||
}
|
||||
|
||||
const row = (c: ContactView) => (
|
||||
<li key={c.id} className={cn("flex items-center gap-3 p-3", c.archived && "opacity-60")}>
|
||||
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Users className="size-4.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Link href={`/cari/${c.id}`} className="truncate font-medium hover:underline">
|
||||
{c.name}
|
||||
</Link>
|
||||
{c.isPrivate ? (
|
||||
<Lock className="size-3.5 shrink-0 text-muted-foreground" aria-label={t.privateBadge} />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{t.relations[c.relation]}</span>
|
||||
{c.lastActivity ? <span>· {t.lastActivity(d(c.lastActivity))}</span> : null}
|
||||
<DueBadge contact={c} />
|
||||
{(inKind[c.id] ?? []).map((x, i) => (
|
||||
<Badge key={i} variant={x.direction === "debt" ? "destructive" : "secondary"}>
|
||||
{x.label}
|
||||
</Badge>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<ContactBalance minor={c.balanceMinor} />
|
||||
{canWrite ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon-sm" variant="ghost" aria-label={tr.recurring.actions.more}>
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{!c.archived ? (
|
||||
<DropdownMenuItem onSelect={() => setEntry({ contact: c })}>
|
||||
<Plus />
|
||||
{t.newEntry}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem onSelect={() => setForm({ contact: c })}>
|
||||
<Pencil />
|
||||
{tr.common.edit}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => archive(c, !c.archived)}>
|
||||
{c.archived ? <ArchiveRestore /> : <Archive />}
|
||||
{c.archived ? t.unarchive : t.archive}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{(
|
||||
[
|
||||
[t.summary.owesMe, data.totals.owesMeMinor, "text-income"],
|
||||
[t.summary.iOwe, data.totals.iOweMinor, "text-expense"],
|
||||
[t.summary.net, data.totals.owesMeMinor - data.totals.iOweMinor, ""],
|
||||
] as const
|
||||
).map(([label, value, tone]) => (
|
||||
<Card key={label} size="sm">
|
||||
<CardHeader>
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<CardTitle className={cn("text-base tabular-nums", tone)}>
|
||||
{formatMoney(value)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{canWrite ? (
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setForm({ contact: null })}>
|
||||
<Plus />
|
||||
{t.new}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{active.length === 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>{t.empty}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
<ul className="divide-y rounded-xl border bg-card">{active.map(row)}</ul>
|
||||
)}
|
||||
{archived.length ? (
|
||||
<Collapsible open={archivedOpen} onOpenChange={setArchivedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="-ml-2 w-fit">
|
||||
<ChevronDown className={cn("transition-transform", archivedOpen && "rotate-180")} />
|
||||
{t.archivedSection(archived.length)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2">
|
||||
<ul className="divide-y rounded-xl border bg-card">{archived.map(row)}</ul>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
<ContactFormDialog
|
||||
open={Boolean(form)}
|
||||
contact={form?.contact ?? null}
|
||||
onOpenChange={(o) => !o && setForm(null)}
|
||||
/>
|
||||
<ContactEntryDialog mode={entry} onClose={() => setEntry(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { HandCoins } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { countDueContacts } from "../service";
|
||||
|
||||
/** Dashboard: vadesi geçmiş veya yaklaşan cari borç/alacak uyarısı (Faz 8 kararı: uygulama içi). */
|
||||
export async function ContactsDueBanner({
|
||||
userId,
|
||||
householdId,
|
||||
}: {
|
||||
userId: string;
|
||||
householdId: string;
|
||||
}) {
|
||||
const n = await countDueContacts(userId, householdId);
|
||||
if (!n) return null;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="flex flex-wrap items-center gap-3 rounded-xl border border-budget/30 bg-budget/10 p-3 text-sm"
|
||||
>
|
||||
<HandCoins className="size-4 shrink-0 text-budget" aria-hidden />
|
||||
<span className="flex-1">{tr.contacts.dueBanner(n)}</span>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href="/cari">{tr.contacts.dueLink}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { listAccounts } from "@/features/accounts/service";
|
||||
import {
|
||||
getTransaction,
|
||||
listTransactions,
|
||||
updateTransaction,
|
||||
} from "@/features/transactions/service";
|
||||
import { currentMonth, getAccountStatement } from "@/features/transactions/statement";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { addDays } from "@/shared/recurrence";
|
||||
import {
|
||||
contactEntrySchema,
|
||||
contactFormSchema,
|
||||
type ContactEntryInput,
|
||||
type ContactFormInput,
|
||||
} from "@/shared/schemas/contact";
|
||||
import { transactionFilterSchema } from "@/shared/schemas/transaction";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import {
|
||||
countDueContacts,
|
||||
createContact,
|
||||
createContactEntry,
|
||||
getContactStatement,
|
||||
listContacts,
|
||||
setContactArchived,
|
||||
updateContact,
|
||||
updateContactEntry,
|
||||
} from "./service";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||
let cakir = "";
|
||||
let ayse = "";
|
||||
let emre = "";
|
||||
let zeynep = "";
|
||||
let fatma = "";
|
||||
const today = todayIstanbul();
|
||||
|
||||
beforeAll(async () => {
|
||||
ids = await setupSeededDb();
|
||||
cakir = ids.household("Çakır Ailesi");
|
||||
ayse = ids.user("ayse");
|
||||
emre = ids.user("emre");
|
||||
zeynep = ids.user("zeynep");
|
||||
fatma = ids.user("fatma");
|
||||
});
|
||||
afterAll(teardownDb);
|
||||
|
||||
const acc = (name: string) =>
|
||||
AccountModel.findOne({ householdId: cakir, name })
|
||||
.lean()
|
||||
.then((a) => String(a!._id));
|
||||
const contactForm = (over: Partial<ContactFormInput> = {}) =>
|
||||
contactFormSchema.parse({ name: "Test Kişi", openingDate: today, ...over });
|
||||
const entry = (over: Partial<ContactEntryInput>) =>
|
||||
contactEntrySchema.parse({ kind: "lend", amount: "1000", date: today, ...over });
|
||||
|
||||
async function balance(userId: string, accountId: string) {
|
||||
return (await listAccounts(userId, cakir))!.accounts.find((a) => a.id === accountId)!
|
||||
.balanceMinor!;
|
||||
}
|
||||
async function contactBalance(userId: string, id: string) {
|
||||
return (await listContacts(userId, cakir))!.contacts.find((c) => c.id === id)!.balanceMinor;
|
||||
}
|
||||
const sums = async () =>
|
||||
(await listTransactions(ayse, cakir, transactionFilterSchema.parse({})))!.sums;
|
||||
|
||||
describe("cari defteri", () => {
|
||||
let ahmet = "";
|
||||
let lendId = "";
|
||||
|
||||
it("borç verme ve tahsilat: hesap bakiyeleri değişir, gelir/gider toplamlarına girmez", async () => {
|
||||
const ziraat = await acc("Ziraat Maaş");
|
||||
const cuzdan = await acc("Cüzdan");
|
||||
const [zBefore, cBefore, sBefore] = [
|
||||
await balance(ayse, ziraat),
|
||||
await balance(ayse, cuzdan),
|
||||
await sums(),
|
||||
];
|
||||
const c = await createContact(
|
||||
ayse,
|
||||
cakir,
|
||||
contactForm({ name: "Kemal Test", relation: "friend" }),
|
||||
);
|
||||
expect(c.ok).toBe(true);
|
||||
ahmet = c.ok ? c.data.id : "";
|
||||
|
||||
const l = await createContactEntry(
|
||||
ayse,
|
||||
cakir,
|
||||
ahmet,
|
||||
entry({ accountId: ziraat, amount: "5.000" }),
|
||||
);
|
||||
expect(l.ok).toBe(true);
|
||||
lendId = l.ok ? l.data.id : "";
|
||||
await createContactEntry(
|
||||
emre,
|
||||
cakir,
|
||||
ahmet,
|
||||
entry({ kind: "collect", accountId: cuzdan, amount: "2.000" }),
|
||||
);
|
||||
|
||||
expect(await balance(ayse, ziraat)).toBe(zBefore - 5_000_00);
|
||||
expect(await balance(ayse, cuzdan)).toBe(cBefore + 2_000_00);
|
||||
expect(await contactBalance(ayse, ahmet)).toBe(3_000_00);
|
||||
expect(await sums()).toEqual(sBefore);
|
||||
|
||||
const detail = (await getTransaction(ayse, cakir, lendId))!;
|
||||
expect(detail).toMatchObject({
|
||||
type: "transfer",
|
||||
contact: { kind: "lend", name: "Kemal Test" },
|
||||
});
|
||||
expect(detail.toAccount).toBeNull();
|
||||
});
|
||||
|
||||
it("borç alma ve geri ödeme; açılış bakiyesi; ekstre yürüyen bakiye", async () => {
|
||||
const ziraat = await acc("Ziraat Maaş");
|
||||
const c = await createContact(
|
||||
ayse,
|
||||
cakir,
|
||||
contactForm({ name: "Hasan Amca", relation: "family", openingBalance: "-1.000" }),
|
||||
);
|
||||
const hasan = c.ok ? c.data.id : "";
|
||||
await createContactEntry(
|
||||
ayse,
|
||||
cakir,
|
||||
hasan,
|
||||
entry({ kind: "borrow", accountId: ziraat, amount: "10.000" }),
|
||||
);
|
||||
await createContactEntry(
|
||||
ayse,
|
||||
cakir,
|
||||
hasan,
|
||||
entry({ kind: "repay", accountId: ziraat, amount: "4.000" }),
|
||||
);
|
||||
expect(await contactBalance(ayse, hasan)).toBe(-7_000_00);
|
||||
|
||||
const s = (await getContactStatement(ayse, cakir, hasan))!;
|
||||
expect(s.rows).toHaveLength(2);
|
||||
expect(s.rows[0]!.balanceMinor).toBe(-7_000_00);
|
||||
expect(s.rows[1]!.balanceMinor).toBe(-11_000_00);
|
||||
expect(s.contact.balanceMinor).toBe(-7_000_00);
|
||||
|
||||
const list = (await listContacts(ayse, cakir))!;
|
||||
expect(list.totals.iOweMinor).toBeGreaterThanOrEqual(7_000_00);
|
||||
expect(list.totals.owesMeMinor).toBeGreaterThanOrEqual(3_000_00);
|
||||
});
|
||||
|
||||
it("hareket düzenleme: tür değişince yön ve bakiyeler değişir; işlem formuyla düzenlenemez", async () => {
|
||||
const ziraat = await acc("Ziraat Maaş");
|
||||
const before = await balance(ayse, ziraat);
|
||||
const u = await updateContactEntry(
|
||||
ayse,
|
||||
cakir,
|
||||
lendId,
|
||||
entry({ kind: "borrow", accountId: ziraat, amount: "5.000" }),
|
||||
);
|
||||
expect(u.ok).toBe(true);
|
||||
expect(await balance(ayse, ziraat)).toBe(before + 10_000_00);
|
||||
expect(await contactBalance(ayse, ahmet)).toBe(-7_000_00);
|
||||
const d = (await getTransaction(ayse, cakir, lendId))!;
|
||||
expect(d.account).toBeNull();
|
||||
expect(d.toAccount?.id).toBe(ziraat);
|
||||
|
||||
const viaForm = await updateTransaction(ayse, cakir, lendId, {
|
||||
type: "transfer",
|
||||
amount: 1,
|
||||
date: today,
|
||||
accountId: ziraat,
|
||||
toAccountId: await acc("Cüzdan"),
|
||||
categoryId: null,
|
||||
splits: [],
|
||||
payee: "",
|
||||
description: "",
|
||||
tags: [],
|
||||
installments: 1,
|
||||
attachmentIds: [],
|
||||
});
|
||||
expect(viaForm.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("vade uyarısı: yaklaşan, geçmiş, kapalı bakiye", async () => {
|
||||
const c = await createContact(
|
||||
ayse,
|
||||
cakir,
|
||||
contactForm({
|
||||
name: "Vade Kişi",
|
||||
openingBalance: "500",
|
||||
dueDate: addDays(today, 2),
|
||||
reminderDays: 3,
|
||||
}),
|
||||
);
|
||||
const id = c.ok ? c.data.id : "";
|
||||
const before = await countDueContacts(ayse, cakir);
|
||||
let v = (await listContacts(ayse, cakir))!.contacts.find((x) => x.id === id)!;
|
||||
expect(v.due).toBe("upcoming");
|
||||
await updateContact(
|
||||
ayse,
|
||||
cakir,
|
||||
id,
|
||||
contactForm({ name: "Vade Kişi", openingBalance: "500", dueDate: addDays(today, -1) }),
|
||||
);
|
||||
v = (await listContacts(ayse, cakir))!.contacts.find((x) => x.id === id)!;
|
||||
expect(v.due).toBe("overdue");
|
||||
await updateContact(
|
||||
ayse,
|
||||
cakir,
|
||||
id,
|
||||
contactForm({ name: "Vade Kişi", openingBalance: "0", dueDate: addDays(today, -1) }),
|
||||
);
|
||||
v = (await listContacts(ayse, cakir))!.contacts.find((x) => x.id === id)!;
|
||||
expect(v.due).toBe("none");
|
||||
expect(await countDueContacts(ayse, cakir)).toBe(before - 1);
|
||||
});
|
||||
|
||||
it("aynı ad engellenir; arşivli cariye hareket eklenmez", async () => {
|
||||
expect((await createContact(ayse, cakir, contactForm({ name: "kemal TEST" }))).ok).toBe(
|
||||
false,
|
||||
);
|
||||
expect((await setContactArchived(ayse, cakir, ahmet, true)).ok).toBe(true);
|
||||
expect(
|
||||
(await createContactEntry(ayse, cakir, ahmet, entry({ accountId: await acc("Cüzdan") }))).ok,
|
||||
).toBe(false);
|
||||
expect((await setContactArchived(ayse, cakir, ahmet, false)).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("yetki ve gizlilik", () => {
|
||||
it("izleyici yazamaz; hane dışı göremez; başka hanenin hesabı kullanılamaz", async () => {
|
||||
expect((await createContact(zeynep, cakir, contactForm({ name: "X" }))).ok).toBe(false);
|
||||
expect(await listContacts(fatma, cakir)).toBeNull();
|
||||
const c = await createContact(ayse, cakir, contactForm({ name: "Hesap Testi" }));
|
||||
const id = c.ok ? c.data.id : "";
|
||||
const otherHousehold = String(
|
||||
(await AccountModel.findOne({ name: "Vakıfbank Emekli" }).lean())!._id,
|
||||
);
|
||||
expect(
|
||||
(await createContactEntry(ayse, cakir, id, entry({ accountId: otherHousehold }))).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("yalnızca ben cari: başkası listede, ekstrede ve işlemlerde görmez; hesap ekstresinde ad gizlenir", async () => {
|
||||
const ziraat = await acc("Ziraat Maaş");
|
||||
const c = await createContact(
|
||||
ayse,
|
||||
cakir,
|
||||
contactForm({ name: "Gizli Kişi", isPrivate: true }),
|
||||
);
|
||||
const id = c.ok ? c.data.id : "";
|
||||
const e = await createContactEntry(
|
||||
ayse,
|
||||
cakir,
|
||||
id,
|
||||
entry({ accountId: ziraat, amount: "750" }),
|
||||
);
|
||||
const txId = e.ok ? e.data.id : "";
|
||||
|
||||
expect((await listContacts(emre, cakir))!.contacts.some((x) => x.id === id)).toBe(false);
|
||||
expect(await getContactStatement(emre, cakir, id)).toBeNull();
|
||||
expect(await getTransaction(emre, cakir, txId)).toBeNull();
|
||||
expect((await createContactEntry(emre, cakir, id, entry({ accountId: ziraat }))).ok).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
const st = (await getAccountStatement(emre, cakir, ziraat, currentMonth()))!;
|
||||
const row = st.rows.find((r) => r.item.id === txId)!;
|
||||
expect(row.restricted).toBe(true);
|
||||
expect(row.item.contact!.name).not.toBe("Gizli Kişi");
|
||||
// Ortak hesabın bakiyesi herkes için aynı.
|
||||
expect(await balance(emre, ziraat)).toBe(await balance(ayse, ziraat));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,444 @@
|
||||
import "server-only";
|
||||
import { Types, type PipelineStage } from "mongoose";
|
||||
import {
|
||||
loadContext,
|
||||
loadOwnTransaction,
|
||||
toItem,
|
||||
userNames,
|
||||
type Ctx,
|
||||
type LeanTx,
|
||||
type TransactionListItem,
|
||||
} from "@/features/transactions/service";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
import { toObjectId } from "@/lib/object-id";
|
||||
import { ContactModel } from "@/models/contact";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import {
|
||||
contactDelta,
|
||||
dueState,
|
||||
isOutgoing,
|
||||
type ContactRelation,
|
||||
type DueState,
|
||||
} from "@/shared/contacts";
|
||||
import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates";
|
||||
import { addDays } from "@/shared/recurrence";
|
||||
import type { ContactEntry, ContactForm } from "@/shared/schemas/contact";
|
||||
import { compareTr, foldTr } from "@/shared/search";
|
||||
|
||||
/**
|
||||
* Cari hesaplar (Bölüm 6.4). Okuma: viewer; yazma: member. Hareketler `transactions` içinde
|
||||
* transferdir (`contactId`, `contactKind`): cariye çıkışta `accountId` dolu/`toAccountId` boş,
|
||||
* cariden girişte tersi. Böylece hesap bakiyesi, ekstre ve mutabakat ek kural gerektirmez;
|
||||
* gelir/gider raporlarına girmez.
|
||||
*/
|
||||
|
||||
const t = tr.contacts;
|
||||
|
||||
type ContactDoc = {
|
||||
_id: Types.ObjectId;
|
||||
householdId: Types.ObjectId;
|
||||
name: string;
|
||||
relation: ContactRelation;
|
||||
phone: string;
|
||||
note: string;
|
||||
openingBalanceMinor: number;
|
||||
openingDate: Date;
|
||||
dueDate?: string | null;
|
||||
reminderDays: number;
|
||||
isPrivate: boolean;
|
||||
ownerUserId?: Types.ObjectId | null;
|
||||
archivedAt?: Date | null;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
const visible = (c: ContactDoc, userId: string) => !c.isPrivate || String(c.ownerUserId) === userId;
|
||||
|
||||
/** Cari bazında hareket toplamı (+ çıkış, − giriş) ve son hareket tarihi. */
|
||||
async function contactTotals(householdId: Types.ObjectId, contactIds?: Types.ObjectId[]) {
|
||||
const pipeline: PipelineStage[] = [
|
||||
{
|
||||
$match: {
|
||||
householdId,
|
||||
status: "posted",
|
||||
deletedAt: null,
|
||||
contactId: contactIds ? { $in: contactIds } : { $type: "objectId" },
|
||||
// Aynî borç hareketleri cari TL bakiyesine girmez.
|
||||
contactKind: { $ne: null },
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: "$contactId",
|
||||
delta: {
|
||||
$sum: {
|
||||
$cond: [
|
||||
{ $in: ["$contactKind", ["lend", "repay"]] },
|
||||
"$amountMinor",
|
||||
{ $multiply: ["$amountMinor", -1] },
|
||||
],
|
||||
},
|
||||
},
|
||||
last: { $max: "$date" },
|
||||
},
|
||||
},
|
||||
];
|
||||
const rows = await TransactionModel.aggregate<{ _id: Types.ObjectId; delta: number; last: Date }>(
|
||||
pipeline,
|
||||
);
|
||||
return new Map(rows.map((r) => [String(r._id), r]));
|
||||
}
|
||||
|
||||
export type ContactView = {
|
||||
id: string;
|
||||
name: string;
|
||||
relation: ContactRelation;
|
||||
phone: string;
|
||||
note: string;
|
||||
/** + bana borçlu, − ben borçluyum. */
|
||||
balanceMinor: number;
|
||||
openingBalanceMinor: number;
|
||||
openingDate: string;
|
||||
dueDate: string | null;
|
||||
reminderDays: number;
|
||||
due: DueState;
|
||||
isPrivate: boolean;
|
||||
archived: boolean;
|
||||
lastActivity: string | null;
|
||||
};
|
||||
|
||||
function toView(
|
||||
c: ContactDoc,
|
||||
totals: Map<string, { delta: number; last: Date }>,
|
||||
today: string,
|
||||
): ContactView {
|
||||
const x = totals.get(String(c._id));
|
||||
const balanceMinor = c.openingBalanceMinor + (x?.delta ?? 0);
|
||||
return {
|
||||
id: String(c._id),
|
||||
name: c.name,
|
||||
relation: c.relation,
|
||||
phone: c.phone,
|
||||
note: c.note,
|
||||
balanceMinor,
|
||||
openingBalanceMinor: c.openingBalanceMinor,
|
||||
openingDate: toIstanbulYmd(c.openingDate),
|
||||
dueDate: c.dueDate ?? null,
|
||||
reminderDays: c.reminderDays,
|
||||
due: dueState(balanceMinor, c.dueDate ?? null, c.reminderDays, today, addDays),
|
||||
isPrivate: c.isPrivate,
|
||||
archived: Boolean(c.archivedAt),
|
||||
lastActivity: x?.last ? toIstanbulYmd(x.last) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export type ContactList = {
|
||||
contacts: ContactView[];
|
||||
totals: { owesMeMinor: number; iOweMinor: number };
|
||||
};
|
||||
|
||||
export async function listContacts(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
): Promise<ContactList | null> {
|
||||
const ctx = await loadContext(userId, householdId, "viewer");
|
||||
if (!ctx) return null;
|
||||
const docs = (await ContactModel.find({ householdId: ctx.m.householdId }).lean<ContactDoc[]>())
|
||||
.filter((c) => visible(c, userId))
|
||||
.sort((a, b) => compareTr(a.name, b.name));
|
||||
const totals = await contactTotals(
|
||||
ctx.m.householdId,
|
||||
docs.map((d) => d._id),
|
||||
);
|
||||
const today = todayIstanbul();
|
||||
const contacts = docs.map((c) => toView(c, totals, today));
|
||||
let owesMeMinor = 0;
|
||||
let iOweMinor = 0;
|
||||
for (const c of contacts) {
|
||||
if (c.archived) continue;
|
||||
if (c.balanceMinor > 0) owesMeMinor += c.balanceMinor;
|
||||
else iOweMinor += -c.balanceMinor;
|
||||
}
|
||||
return { contacts, totals: { owesMeMinor, iOweMinor } };
|
||||
}
|
||||
|
||||
async function loadContact(ctx: Ctx, contactId: string, userId: string) {
|
||||
const id = toObjectId(contactId);
|
||||
if (!id) return null;
|
||||
const c = await ContactModel.findOne({
|
||||
_id: id,
|
||||
householdId: ctx.m.householdId,
|
||||
}).lean<ContactDoc>();
|
||||
return c && visible(c, userId) ? c : null;
|
||||
}
|
||||
|
||||
async function nameTaken(householdId: Types.ObjectId, name: string, exceptId?: Types.ObjectId) {
|
||||
const key = foldTr(name);
|
||||
const others = await ContactModel.find(
|
||||
{ householdId, archivedAt: null, ...(exceptId ? { _id: { $ne: exceptId } } : {}) },
|
||||
{ name: 1 },
|
||||
).lean();
|
||||
return others.some((o) => foldTr(o.name) === key);
|
||||
}
|
||||
|
||||
// --- Cari yazma ---
|
||||
|
||||
export async function createContact(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
input: ContactForm,
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
if (await nameTaken(ctx.m.householdId, input.name))
|
||||
return fail(t.errors.duplicate, { name: t.errors.duplicate });
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
const doc = await ContactModel.create({
|
||||
householdId: ctx.m.householdId,
|
||||
name: input.name,
|
||||
relation: input.relation,
|
||||
phone: input.phone,
|
||||
note: input.note,
|
||||
openingBalanceMinor: input.openingBalance,
|
||||
openingDate: istanbulDayStart(input.openingDate)!,
|
||||
dueDate: input.dueDate,
|
||||
reminderDays: input.reminderDays,
|
||||
isPrivate: input.isPrivate,
|
||||
ownerUserId: input.isPrivate ? actor : null,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
return ok({ id: String(doc._id) });
|
||||
}
|
||||
|
||||
export async function updateContact(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
contactId: string,
|
||||
input: ContactForm,
|
||||
): Promise<ActionResult> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const c = await loadContact(ctx, contactId, actorId);
|
||||
if (!c) return fail(tr.common.notFound);
|
||||
if (await nameTaken(ctx.m.householdId, input.name, c._id))
|
||||
return fail(t.errors.duplicate, { name: t.errors.duplicate });
|
||||
// Gizlilik yalnızca sahibince (veya ortak cariyi gizlerken işlemi yapanca) belirlenir.
|
||||
const owner = c.ownerUserId ?? (input.isPrivate ? new Types.ObjectId(actorId) : null);
|
||||
await ContactModel.updateOne(
|
||||
{ _id: c._id, householdId: ctx.m.householdId },
|
||||
{
|
||||
$set: {
|
||||
name: input.name,
|
||||
relation: input.relation,
|
||||
phone: input.phone,
|
||||
note: input.note,
|
||||
openingBalanceMinor: input.openingBalance,
|
||||
openingDate: istanbulDayStart(input.openingDate)!,
|
||||
dueDate: input.dueDate,
|
||||
reminderDays: input.reminderDays,
|
||||
isPrivate: input.isPrivate,
|
||||
ownerUserId: input.isPrivate ? owner : null,
|
||||
updatedBy: new Types.ObjectId(actorId),
|
||||
},
|
||||
},
|
||||
);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
export async function setContactArchived(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
contactId: string,
|
||||
archived: boolean,
|
||||
): Promise<ActionResult> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const c = await loadContact(ctx, contactId, actorId);
|
||||
if (!c) return fail(tr.common.notFound);
|
||||
if (!archived && (await nameTaken(ctx.m.householdId, c.name, c._id)))
|
||||
return fail(t.errors.duplicate);
|
||||
await ContactModel.updateOne(
|
||||
{ _id: c._id, householdId: ctx.m.householdId },
|
||||
{ $set: { archivedAt: archived ? new Date() : null, updatedBy: new Types.ObjectId(actorId) } },
|
||||
);
|
||||
await writeAudit({
|
||||
action: archived ? "contact.archive" : "contact.unarchive",
|
||||
actorId: new Types.ObjectId(actorId),
|
||||
householdId: ctx.m.householdId,
|
||||
targetType: "contact",
|
||||
targetId: String(c._id),
|
||||
diff: { name: c.name },
|
||||
});
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
// --- Cari hareketleri ---
|
||||
|
||||
function entryFields(ctx: Ctx, contact: ContactDoc, input: ContactEntry, keepAccount?: string) {
|
||||
const info = ctx.accountMap.get(input.accountId);
|
||||
if (!info || info.hidden || (info.archived && input.accountId !== keepAccount)) {
|
||||
return fail(tr.transactions.validation.accountInvalid, {
|
||||
accountId: tr.transactions.validation.accountInvalid,
|
||||
});
|
||||
}
|
||||
const date = istanbulDayStart(input.date);
|
||||
if (!date) return fail(tr.validation.date, { date: tr.validation.date });
|
||||
const out = isOutgoing(input.kind);
|
||||
return ok({
|
||||
type: "transfer" as const,
|
||||
date,
|
||||
accountId: out ? info.id : null,
|
||||
toAccountId: out ? null : info.id,
|
||||
contactId: contact._id,
|
||||
contactKind: input.kind,
|
||||
amountMinor: input.amount,
|
||||
categoryId: null,
|
||||
splits: [],
|
||||
effectiveCategoryIds: [],
|
||||
payeeName: "",
|
||||
description: input.description,
|
||||
searchText: foldTr(`${contact.name} ${input.description}`),
|
||||
});
|
||||
}
|
||||
|
||||
async function applyDue(contact: ContactDoc, input: ContactEntry, actor: Types.ObjectId) {
|
||||
if (input.dueDate === undefined) return;
|
||||
await ContactModel.updateOne(
|
||||
{ _id: contact._id },
|
||||
{
|
||||
$set: {
|
||||
dueDate: input.dueDate,
|
||||
...(input.reminderDays !== undefined ? { reminderDays: input.reminderDays } : {}),
|
||||
updatedBy: actor,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function createContactEntry(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
contactId: string,
|
||||
input: ContactEntry,
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const contact = await loadContact(ctx, contactId, actorId);
|
||||
if (!contact) return fail(tr.common.notFound);
|
||||
if (contact.archivedAt) return fail(t.errors.archived);
|
||||
const fields = entryFields(ctx, contact, input);
|
||||
if (!fields.ok) return fields;
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
const doc = await TransactionModel.create({
|
||||
...fields.data,
|
||||
householdId: ctx.m.householdId,
|
||||
status: "posted",
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
await applyDue(contact, input, actor);
|
||||
return ok({ id: String(doc._id) });
|
||||
}
|
||||
|
||||
export async function updateContactEntry(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
transactionId: string,
|
||||
input: ContactEntry,
|
||||
): Promise<ActionResult> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const tx = await loadOwnTransaction(ctx, transactionId);
|
||||
if (!tx?.contactId) return fail(tr.common.notFound);
|
||||
const contact = await loadContact(ctx, String(tx.contactId), actorId);
|
||||
if (!contact) return fail(tr.common.notFound);
|
||||
const keep = String(tx.accountId ?? tx.toAccountId ?? "");
|
||||
const fields = entryFields(ctx, contact, input, keep);
|
||||
if (!fields.ok) return fields;
|
||||
const d = fields.data;
|
||||
const balanceChanged =
|
||||
d.amountMinor !== tx.amountMinor ||
|
||||
d.date.getTime() !== new Date(tx.date).getTime() ||
|
||||
String(d.accountId ?? "") !== String(tx.accountId ?? "") ||
|
||||
String(d.toAccountId ?? "") !== String(tx.toAccountId ?? "");
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
await TransactionModel.updateOne(
|
||||
{ _id: tx._id, householdId: ctx.m.householdId },
|
||||
{
|
||||
$set: {
|
||||
...d,
|
||||
updatedBy: actor,
|
||||
...(balanceChanged ? { reconciledAccountIds: [] } : {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
await applyDue(contact, input, actor);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
// --- Ekstre ---
|
||||
|
||||
export type ContactStatementRow = {
|
||||
item: TransactionListItem;
|
||||
deltaMinor: number;
|
||||
balanceMinor: number;
|
||||
};
|
||||
|
||||
export type ContactStatement = {
|
||||
contact: ContactView;
|
||||
/** Yeniden eskiye. */
|
||||
rows: ContactStatementRow[];
|
||||
canWrite: boolean;
|
||||
};
|
||||
|
||||
export async function getContactStatement(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
contactId: string,
|
||||
): Promise<ContactStatement | null> {
|
||||
const ctx = await loadContext(userId, householdId, "viewer");
|
||||
if (!ctx) return null;
|
||||
const contact = await loadContact(ctx, contactId, userId);
|
||||
if (!contact) return null;
|
||||
const txs = (await TransactionModel.find({
|
||||
householdId: ctx.m.householdId,
|
||||
contactId: contact._id,
|
||||
contactKind: { $ne: null },
|
||||
status: "posted",
|
||||
deletedAt: null,
|
||||
})
|
||||
.sort({ date: 1, createdAt: 1 })
|
||||
.limit(5000)
|
||||
.lean()) as unknown as LeanTx[];
|
||||
const names = await userNames(txs.map((x) => x.createdBy as Types.ObjectId));
|
||||
let balance = contact.openingBalanceMinor;
|
||||
const rows = txs.map((x): ContactStatementRow => {
|
||||
const deltaMinor = contactDelta(x.contactKind!, x.amountMinor);
|
||||
balance += deltaMinor;
|
||||
const item = toItem(ctx, x, names);
|
||||
// Başkasının gizli hesabıyla yapılan hareket: hesap adı gizlenir.
|
||||
for (const a of [item.account, item.toAccount])
|
||||
if (a && ctx.accountMap.get(a.id)?.hidden) a.name = tr.transactions.list.hiddenAccount;
|
||||
return { item, deltaMinor, balanceMinor: balance };
|
||||
});
|
||||
const totals = new Map([
|
||||
[
|
||||
String(contact._id),
|
||||
{ delta: balance - contact.openingBalanceMinor, last: txs.at(-1)?.date ?? null },
|
||||
],
|
||||
]) as Map<string, { delta: number; last: Date }>;
|
||||
return {
|
||||
contact: toView(contact, totals, todayIstanbul()),
|
||||
rows: rows.reverse(),
|
||||
canWrite: hasHouseholdRole(ctx.m.role, "member"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Dashboard: vadesi geçmiş veya yaklaşan (hatırlatma penceresindeki) cari sayısı. */
|
||||
export async function countDueContacts(userId: string, householdId: string): Promise<number> {
|
||||
const list = await listContacts(userId, householdId);
|
||||
return list?.contacts.filter((c) => !c.archived && c.due !== "none").length ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, type ActionResult } from "@/lib/action-result";
|
||||
import { getHouseholdActionContext } from "@/lib/dal";
|
||||
import {
|
||||
assetPriceFormSchema,
|
||||
contributionSchema,
|
||||
inKindFormSchema,
|
||||
paymentFormSchema,
|
||||
} from "@/shared/schemas/in-kind";
|
||||
import {
|
||||
addContribution,
|
||||
createInKind,
|
||||
deleteInKind,
|
||||
recordPayment,
|
||||
savePrice,
|
||||
updateInKind,
|
||||
} from "./service";
|
||||
|
||||
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
|
||||
|
||||
function fieldErrors(error: z.ZodError) {
|
||||
const out: Record<string, string> = {};
|
||||
for (const issue of error.issues) {
|
||||
const key = issue.path.join(".");
|
||||
if (key && !out[key]) out[key] = issue.message;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const revalidate = () => revalidatePath("/", "layout");
|
||||
|
||||
async function run<T>(
|
||||
schema: z.ZodType<T>,
|
||||
input: unknown,
|
||||
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<unknown>>,
|
||||
): Promise<ActionResult<unknown>> {
|
||||
const ctx = await getHouseholdActionContext("member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const parsed = schema.safeParse(input);
|
||||
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||
const result = await fn(ctx.user.id, ctx.household.id, parsed.data);
|
||||
if (result.ok) revalidate();
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function savePriceAction(input: unknown) {
|
||||
return run(assetPriceFormSchema, input, savePrice);
|
||||
}
|
||||
|
||||
/** Yeni aynî kayıt (debtId boş, contactId dolu) veya mevcut kaydı düzenle. */
|
||||
export async function saveInKindAction(contactId: unknown, debtId: unknown, input: unknown) {
|
||||
if (debtId) {
|
||||
const id = idSchema.safeParse(debtId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(inKindFormSchema, input, (u, h, d) => updateInKind(u, h, id.data, d));
|
||||
}
|
||||
const cid = idSchema.safeParse(contactId);
|
||||
if (!cid.success) return fail(tr.common.notFound);
|
||||
return run(inKindFormSchema, input, (u, h, d) => createInKind(u, h, cid.data, d));
|
||||
}
|
||||
|
||||
export async function deleteInKindAction(debtId: unknown): Promise<ActionResult<unknown>> {
|
||||
const id = idSchema.safeParse(debtId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(z.unknown(), null, (u, h) => deleteInKind(u, h, id.data));
|
||||
}
|
||||
|
||||
export async function addContributionAction(debtId: unknown, input: unknown) {
|
||||
const id = idSchema.safeParse(debtId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(contributionSchema, input, (u, h, d) => addContribution(u, h, id.data, d));
|
||||
}
|
||||
|
||||
export async function recordPaymentAction(debtId: unknown, input: unknown) {
|
||||
const id = idSchema.safeParse(debtId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(paymentFormSchema, input, (u, h, d) => recordPayment(u, h, id.data, d));
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
"use client";
|
||||
|
||||
import { Coins, EllipsisVertical, HandCoins, Pencil, PiggyBank, Plus, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { MoneyInput } from "@/components/money-input";
|
||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { getTransactionFormOptionsAction } from "@/features/transactions/actions";
|
||||
import { AccountSelect } from "@/features/transactions/components/transaction-form";
|
||||
import type { TransactionFormOptions } from "@/features/transactions/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ASSET_KEYS, ASSETS, formatAssetQuantity, type AssetKey } from "@/shared/assets";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { formatDate, formatMoney } from "@/shared/format";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
import { QUANTITY_SCALE } from "@/shared/quantity";
|
||||
import {
|
||||
addContributionAction,
|
||||
deleteInKindAction,
|
||||
recordPaymentAction,
|
||||
saveInKindAction,
|
||||
} from "../actions";
|
||||
import type { InKindView } from "../service";
|
||||
|
||||
const t = tr.inKind;
|
||||
type Errors = Record<string, string | undefined>;
|
||||
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||
const monthFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "Europe/Istanbul",
|
||||
});
|
||||
const qtyInput = (q: number) =>
|
||||
(q / QUANTITY_SCALE).toLocaleString("tr-TR", { maximumFractionDigits: 3, useGrouping: false });
|
||||
|
||||
/** Hesap seçenekleri (diyaloglar açıldığında). */
|
||||
function useAccounts() {
|
||||
const [options, setOptions] = useState<TransactionFormOptions | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getTransactionFormOptionsAction().then((r) => {
|
||||
if (!cancelled && r.ok) setOptions(r.data);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
return options?.accounts ?? null;
|
||||
}
|
||||
|
||||
function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className={cn("font-semibold tabular-nums", tone)}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Pending =
|
||||
| { kind: "form"; item: InKindView | null }
|
||||
| { kind: "contribute"; item: InKindView }
|
||||
| { kind: "pay"; item: InKindView }
|
||||
| { kind: "delete"; item: InKindView }
|
||||
| null;
|
||||
|
||||
/** Cari sayfasında aynî borç/alacak bölümü. */
|
||||
export function InKindSection({
|
||||
contactId,
|
||||
items,
|
||||
canWrite,
|
||||
}: {
|
||||
contactId: string;
|
||||
items: InKindView[];
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const [pending, setPending] = useState<Pending>(null);
|
||||
const close = () => setPending(null);
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3" aria-labelledby="in-kind-title">
|
||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||
<div>
|
||||
<h2 id="in-kind-title" className="text-base font-medium">
|
||||
{t.title}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">{t.description}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/fiyatlar">
|
||||
<Coins />
|
||||
{t.prices}
|
||||
</Link>
|
||||
</Button>
|
||||
{canWrite ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setPending({ kind: "form", item: null })}
|
||||
>
|
||||
<Plus />
|
||||
{t.new}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.map((item) => (
|
||||
<InKindCard key={item.id} item={item} canWrite={canWrite} onAction={setPending} />
|
||||
))}
|
||||
|
||||
{pending?.kind === "form" ? (
|
||||
<InKindForm contactId={contactId} item={pending.item} onClose={close} />
|
||||
) : null}
|
||||
{pending?.kind === "contribute" ? (
|
||||
<ContributionForm item={pending.item} onClose={close} />
|
||||
) : null}
|
||||
{pending?.kind === "pay" ? <PaymentForm item={pending.item} onClose={close} /> : null}
|
||||
<AlertDialog open={pending?.kind === "delete"} onOpenChange={(o) => !o && close()}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t.deleteTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t.deleteDescription}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
if (pending?.kind !== "delete") return;
|
||||
const r = await deleteInKindAction(pending.item.id);
|
||||
close();
|
||||
if (r.ok) toast.success(t.toast.deleted);
|
||||
else toast.error(r.error);
|
||||
}}
|
||||
>
|
||||
{t.delete}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function InKindCard({
|
||||
item,
|
||||
canWrite,
|
||||
onAction,
|
||||
}: {
|
||||
item: InKindView;
|
||||
canWrite: boolean;
|
||||
onAction: (p: Pending) => void;
|
||||
}) {
|
||||
const p = item.progress;
|
||||
const debt = item.direction === "debt";
|
||||
const open = !item.closedAt;
|
||||
return (
|
||||
<Card size="sm" className={cn(!open && "opacity-70")}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="flex flex-wrap items-center gap-2 text-base">
|
||||
{item.label}
|
||||
<Badge variant={debt ? "destructive" : "secondary"}>
|
||||
{t.directions[item.direction]}
|
||||
</Badge>
|
||||
{item.closedAt ? (
|
||||
<Badge variant="outline">{t.stats.closed(d(item.closedAt))}</Badge>
|
||||
) : null}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t.stats.dateValue(d(item.date))}:{" "}
|
||||
{item.valueMinor !== null ? formatMoney(item.valueMinor) : "—"}
|
||||
{item.unitPriceMinor !== null
|
||||
? ` · ${t.stats.priceAt(formatMoney(item.unitPriceMinor), d(item.date))}`
|
||||
: ""}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{canWrite ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon-sm" variant="ghost" aria-label={tr.recurring.actions.more}>
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => onAction({ kind: "form", item })}>
|
||||
<Pencil />
|
||||
{tr.common.edit}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => onAction({ kind: "delete", item })}
|
||||
>
|
||||
<Trash2 />
|
||||
{t.delete}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Stat
|
||||
label={t.stats.remaining}
|
||||
value={formatAssetQuantity(item.assetKey, p.remainingQuantity)}
|
||||
/>
|
||||
<Stat
|
||||
label={t.stats.currentValue}
|
||||
value={
|
||||
p.currentValueMinor !== null ? formatMoney(p.currentValueMinor) : t.stats.noPrice
|
||||
}
|
||||
tone={debt ? "text-expense" : "text-income"}
|
||||
/>
|
||||
{item.savingsAccount ? (
|
||||
<>
|
||||
<Stat
|
||||
label={`${t.stats.accumulated} · ${item.savingsAccount.name}`}
|
||||
value={formatMoney(p.accumulatedMinor)}
|
||||
tone="text-income"
|
||||
/>
|
||||
<Stat
|
||||
label={t.stats.remainingNeed}
|
||||
value={p.remainingNeedMinor !== null ? formatMoney(p.remainingNeedMinor) : "—"}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{p.estimatedCloseMonth && open ? (
|
||||
<p className="text-sm">
|
||||
{t.stats.estimatedClose}:{" "}
|
||||
<span className="font-medium capitalize">
|
||||
{monthFmt.format(new Date(`${p.estimatedCloseMonth}-15T12:00:00+03:00`))}
|
||||
</span>
|
||||
{item.monthlyPlanMinor ? (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
· {formatMoney(item.monthlyPlanMinor)}/ay
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
{item.currentPrice ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.stats.currentPrice(
|
||||
debt,
|
||||
formatMoney(debt ? item.currentPrice.sellMinor : item.currentPrice.buyMinor),
|
||||
d(item.currentPrice.date),
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
{item.note ? <p className="text-sm text-muted-foreground">{item.note}</p> : null}
|
||||
{item.payments.length ? (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">{t.payments}</p>
|
||||
<ul className="divide-y rounded-lg border text-sm">
|
||||
{item.payments.map((x) => (
|
||||
<li key={x.id} className="flex items-center justify-between gap-2 p-2">
|
||||
<span className="min-w-0 truncate">
|
||||
{d(x.date)} ·{" "}
|
||||
{t.paymentLine(
|
||||
t.methods[x.method],
|
||||
formatAssetQuantity(item.assetKey, x.quantity),
|
||||
x.amountMinor ? formatMoney(x.amountMinor) : null,
|
||||
)}
|
||||
</span>
|
||||
{x.account ? (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{x.account}</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
{canWrite && open ? (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{debt && item.savingsAccount ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onAction({ kind: "contribute", item })}
|
||||
>
|
||||
<PiggyBank />
|
||||
{t.contribute}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" onClick={() => onAction({ kind: "pay", item })}>
|
||||
<HandCoins />
|
||||
{t.pay}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Footer({ busy, formId, onClose }: { busy: boolean; formId: string; onClose: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={busy}>
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InKindForm({
|
||||
contactId,
|
||||
item,
|
||||
onClose,
|
||||
}: {
|
||||
contactId: string;
|
||||
item: InKindView | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const accounts = useAccounts();
|
||||
const [v, setV] = useState({
|
||||
direction: item?.direction ?? "debt",
|
||||
assetKey: (item?.assetKey ?? "gram_gold") as AssetKey,
|
||||
quantity: item ? qtyInput(item.quantity) : "",
|
||||
date: item?.date ?? todayIstanbul(),
|
||||
unitPrice: item?.unitPriceMinor ? formatMoneyInput(item.unitPriceMinor) : "",
|
||||
note: item?.note ?? "",
|
||||
savingsAccountId: item?.savingsAccount?.id ?? "",
|
||||
monthlyPlan: item?.monthlyPlanMinor ? formatMoneyInput(item.monthlyPlanMinor) : "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
|
||||
const NONE = "__none__";
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await saveInKindAction(contactId, item?.id ?? null, {
|
||||
...v,
|
||||
savingsAccountId: v.direction === "debt" ? v.savingsAccountId : "",
|
||||
monthlyPlan: v.direction === "debt" && v.savingsAccountId ? v.monthlyPlan : "",
|
||||
});
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.toast.saved);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
const f = t.fields;
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={item ? t.editTitle : t.createTitle}
|
||||
footer={<Footer busy={busy} formId="in-kind-form" onClose={onClose} />}
|
||||
>
|
||||
<form id="in-kind-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Tabs
|
||||
value={v.direction}
|
||||
onValueChange={(x) => set("direction", x as "debt" | "receivable")}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
{(["debt", "receivable"] as const).map((x) => (
|
||||
<TabsTrigger
|
||||
key={x}
|
||||
value={x}
|
||||
className="flex-1"
|
||||
disabled={Boolean(item?.payments.length)}
|
||||
>
|
||||
{t.directions[x]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="grid grid-cols-[1fr_8rem] gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ik-asset">{f.asset}</FieldLabel>
|
||||
<Select
|
||||
value={v.assetKey}
|
||||
onValueChange={(x) => set("assetKey", x as AssetKey)}
|
||||
disabled={Boolean(item?.payments.length)}
|
||||
>
|
||||
<SelectTrigger id="ik-asset" className="w-full">
|
||||
<SelectValue>{ASSETS[v.assetKey].label}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ASSET_KEYS.map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{ASSETS[k].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.quantity)}>
|
||||
<FieldLabel htmlFor="ik-qty">
|
||||
{f.quantity} ({ASSETS[v.assetKey].unit})
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="ik-qty"
|
||||
inputMode="decimal"
|
||||
value={v.quantity}
|
||||
onChange={(e) => set("quantity", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.quantity)}
|
||||
/>
|
||||
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.date)}>
|
||||
<FieldLabel htmlFor="ik-date">{f.date}</FieldLabel>
|
||||
<Input
|
||||
id="ik-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
onChange={(e) => set("date", e.target.value)}
|
||||
/>
|
||||
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.unitPrice)}>
|
||||
<FieldLabel htmlFor="ik-price">{f.unitPrice}</FieldLabel>
|
||||
<MoneyInput id="ik-price" value={v.unitPrice} onChange={(x) => set("unitPrice", x)} />
|
||||
{errors.unitPrice ? <FieldError>{errors.unitPrice}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<FieldDescription className="-mt-2">{f.unitPriceHint}</FieldDescription>
|
||||
{v.direction === "debt" ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.savingsAccountId)}>
|
||||
<FieldLabel htmlFor="ik-savings">{f.savingsAccount}</FieldLabel>
|
||||
<Select
|
||||
value={v.savingsAccountId || NONE}
|
||||
onValueChange={(x) => set("savingsAccountId", x === NONE ? "" : x)}
|
||||
>
|
||||
<SelectTrigger id="ik-savings" className="w-full">
|
||||
<SelectValue>
|
||||
{accounts?.find((a) => a.id === v.savingsAccountId)?.name ??
|
||||
(v.savingsAccountId ? item?.savingsAccount?.name : f.savingsNone)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>{f.savingsNone}</SelectItem>
|
||||
{(accounts ?? [])
|
||||
.filter((a) => a.type !== "credit_card")
|
||||
.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.savingsAccountId ? (
|
||||
<FieldError>{errors.savingsAccountId}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ik-plan">{f.monthlyPlan}</FieldLabel>
|
||||
<MoneyInput
|
||||
id="ik-plan"
|
||||
value={v.monthlyPlan}
|
||||
onChange={(x) => set("monthlyPlan", x)}
|
||||
disabled={!v.savingsAccountId}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
{v.direction === "debt" ? (
|
||||
<FieldDescription className="-mt-2">{f.planHint}</FieldDescription>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ik-note">{f.note}</FieldLabel>
|
||||
<Input id="ik-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||||
</Field>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ContributionForm({ item, onClose }: { item: InKindView; onClose: () => void }) {
|
||||
const accounts = useAccounts();
|
||||
const [v, setV] = useState({
|
||||
fromAccountId: "",
|
||||
amount: item.monthlyPlanMinor ? formatMoneyInput(item.monthlyPlanMinor) : "",
|
||||
date: todayIstanbul(),
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const sources = (accounts ?? []).filter((a) => a.id !== item.savingsAccount?.id);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await addContributionAction(item.id, {
|
||||
...v,
|
||||
fromAccountId: v.fromAccountId || sources[0]?.id || "",
|
||||
});
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.toast.contributed);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={`${t.contributionTitle} · ${item.label}`}
|
||||
description={`→ ${item.savingsAccount?.name ?? ""}`}
|
||||
footer={<Footer busy={busy} formId="contribution-form" onClose={onClose} />}
|
||||
>
|
||||
<form id="contribution-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={Boolean(errors.fromAccountId)}>
|
||||
<FieldLabel htmlFor="ct-from">{t.fromAccount}</FieldLabel>
|
||||
<AccountSelect
|
||||
id="ct-from"
|
||||
value={v.fromAccountId || sources[0]?.id || ""}
|
||||
onChange={(x) => setV((s) => ({ ...s, fromAccountId: x }))}
|
||||
accounts={sources}
|
||||
/>
|
||||
{errors.fromAccountId ? <FieldError>{errors.fromAccountId}</FieldError> : null}
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.amount)}>
|
||||
<FieldLabel htmlFor="ct-amount">{tr.transactions.fields.amount}</FieldLabel>
|
||||
<MoneyInput
|
||||
id="ct-amount"
|
||||
value={v.amount}
|
||||
onChange={(x) => setV((s) => ({ ...s, amount: x }))}
|
||||
/>
|
||||
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ct-date">{tr.transactions.fields.date}</FieldLabel>
|
||||
<Input
|
||||
id="ct-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
onChange={(e) => setV((s) => ({ ...s, date: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentForm({ item, onClose }: { item: InKindView; onClose: () => void }) {
|
||||
const accounts = useAccounts();
|
||||
const debt = item.direction === "debt";
|
||||
const [method, setMethod] = useState<"asset" | "cash">("cash");
|
||||
const [v, setV] = useState({
|
||||
date: todayIstanbul(),
|
||||
quantity: qtyInput(item.progress.remainingQuantity),
|
||||
amount: "",
|
||||
accountId: item.savingsAccount?.id ?? "",
|
||||
note: "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await recordPaymentAction(item.id, {
|
||||
method,
|
||||
date: v.date,
|
||||
quantity: method === "cash" ? "" : v.quantity,
|
||||
amount: v.amount,
|
||||
accountId: v.amount ? v.accountId || accounts?.[0]?.id || "" : "",
|
||||
note: v.note,
|
||||
});
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success((r.data as { closed: boolean }).closed ? t.toast.closed : t.toast.paid);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
const f = t.payFields;
|
||||
const hints = debt ? t.methodHints : t.receiveHints;
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={`${t.payTitle} · ${item.label}`}
|
||||
description={`${t.stats.remaining}: ${formatAssetQuantity(item.assetKey, item.progress.remainingQuantity)}`}
|
||||
footer={<Footer busy={busy} formId="payment-form" onClose={onClose} />}
|
||||
>
|
||||
<form id="payment-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Tabs value={method} onValueChange={(x) => setMethod(x as "asset" | "cash")}>
|
||||
<TabsList className="w-full">
|
||||
{(["cash", "asset"] as const).map((m) => (
|
||||
<TabsTrigger key={m} value={m} className="flex-1">
|
||||
{t.methods[m]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<p className="-mt-2 text-xs text-muted-foreground">{hints[method]}</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{method === "asset" ? (
|
||||
<Field data-invalid={Boolean(errors.quantity)}>
|
||||
<FieldLabel htmlFor="pay-qty">
|
||||
{f.quantity} ({ASSETS[item.assetKey].unit})
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="pay-qty"
|
||||
inputMode="decimal"
|
||||
value={v.quantity}
|
||||
onChange={(e) => setV((s) => ({ ...s, quantity: e.target.value }))}
|
||||
className="text-right tabular-nums"
|
||||
/>
|
||||
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
||||
</Field>
|
||||
) : null}
|
||||
<Field data-invalid={Boolean(errors.amount)}>
|
||||
<FieldLabel htmlFor="pay-amount">
|
||||
{method === "cash" ? f.amount : f.assetAmount}
|
||||
</FieldLabel>
|
||||
<MoneyInput
|
||||
id="pay-amount"
|
||||
value={v.amount}
|
||||
onChange={(x) => setV((s) => ({ ...s, amount: x }))}
|
||||
/>
|
||||
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pay-date">{tr.transactions.fields.date}</FieldLabel>
|
||||
<Input
|
||||
id="pay-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
max={todayIstanbul()}
|
||||
onChange={(e) => setV((s) => ({ ...s, date: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
{method === "cash" ? (
|
||||
<FieldDescription className="-mt-2">{f.quantityHint}</FieldDescription>
|
||||
) : null}
|
||||
{method === "cash" || v.amount ? (
|
||||
<Field data-invalid={Boolean(errors.accountId)}>
|
||||
<FieldLabel htmlFor="pay-account">{f.account}</FieldLabel>
|
||||
<AccountSelect
|
||||
id="pay-account"
|
||||
value={v.accountId || accounts?.[0]?.id || ""}
|
||||
onChange={(x) => setV((s) => ({ ...s, accountId: x }))}
|
||||
accounts={accounts ?? []}
|
||||
invalid={Boolean(errors.accountId)}
|
||||
/>
|
||||
{errors.accountId ? <FieldError>{errors.accountId}</FieldError> : null}
|
||||
</Field>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pay-note">{t.fields.note}</FieldLabel>
|
||||
<Input
|
||||
id="pay-note"
|
||||
value={v.note}
|
||||
onChange={(e) => setV((s) => ({ ...s, note: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, Plus } from "lucide-react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { MoneyInput } from "@/components/money-input";
|
||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ASSET_KEYS, ASSETS, type AssetKey } from "@/shared/assets";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { formatDate, formatMoney } from "@/shared/format";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
import { savePriceAction } from "../actions";
|
||||
import type { PriceRow } from "../service";
|
||||
|
||||
const t = tr.prices;
|
||||
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||
|
||||
export function PricesView({ rows, canWrite }: { rows: PriceRow[]; canWrite: boolean }) {
|
||||
const [editing, setEditing] = useState<AssetKey | null>(null);
|
||||
const [openRow, setOpenRow] = useState<AssetKey | null>(null);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{canWrite ? (
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setEditing("gram_gold")}>
|
||||
<Plus />
|
||||
{t.add}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-muted-foreground">
|
||||
<tr className="border-b">
|
||||
<th className="p-3 text-left font-normal">{t.asset}</th>
|
||||
<th className="p-3 text-right font-normal">{t.buy}</th>
|
||||
<th className="p-3 text-right font-normal">{t.sell}</th>
|
||||
<th className="p-3 text-right font-normal">{t.date}</th>
|
||||
<th className="w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y tabular-nums">
|
||||
{rows.map((r) => (
|
||||
<Fragment key={r.assetKey}>
|
||||
<tr>
|
||||
<td className="p-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-left font-medium disabled:cursor-default"
|
||||
onClick={() => setOpenRow(openRow === r.assetKey ? null : r.assetKey)}
|
||||
disabled={r.history.length < 2}
|
||||
aria-expanded={openRow === r.assetKey}
|
||||
>
|
||||
{r.history.length > 1 ? (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
openRow === r.assetKey && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{ASSETS[r.assetKey].label}
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{t.perUnit(ASSETS[r.assetKey].unit)}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{r.latest ? formatMoney(r.latest.buyMinor) : "—"}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{r.latest ? formatMoney(r.latest.sellMinor) : "—"}
|
||||
</td>
|
||||
<td className="p-3 text-right text-muted-foreground">
|
||||
{r.latest ? d(r.latest.date) : t.noPrice}
|
||||
</td>
|
||||
<td className="p-1 text-right">
|
||||
{canWrite ? (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => setEditing(r.assetKey)}
|
||||
aria-label={`${t.add}: ${ASSETS[r.assetKey].label}`}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
{openRow === r.assetKey
|
||||
? r.history.slice(1).map((h) => (
|
||||
<tr key={h.date} className="bg-muted/30 text-muted-foreground">
|
||||
<td className="p-2 pl-9 text-xs">{t.history}</td>
|
||||
<td className="p-2 text-right">{formatMoney(h.buyMinor)}</td>
|
||||
<td className="p-2 text-right">{formatMoney(h.sellMinor)}</td>
|
||||
<td className="p-2 text-right">{d(h.date)}</td>
|
||||
<td />
|
||||
</tr>
|
||||
))
|
||||
: null}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{editing ? (
|
||||
<PriceForm
|
||||
key={editing}
|
||||
assetKey={editing}
|
||||
latest={rows.find((r) => r.assetKey === editing)?.latest ?? null}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PriceForm({
|
||||
assetKey: initial,
|
||||
latest,
|
||||
onClose,
|
||||
}: {
|
||||
assetKey: AssetKey;
|
||||
latest: PriceRow["latest"];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [v, setV] = useState({
|
||||
assetKey: initial,
|
||||
date: todayIstanbul(),
|
||||
buy: latest ? formatMoneyInput(latest.buyMinor) : "",
|
||||
sell: latest ? formatMoneyInput(latest.sellMinor) : "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await savePriceAction(v);
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.saved);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={t.add}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form="price-form" disabled={busy}>
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="price-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-[1fr_auto] gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pr-asset">{t.asset}</FieldLabel>
|
||||
<Select
|
||||
value={v.assetKey}
|
||||
onValueChange={(x) => setV((s) => ({ ...s, assetKey: x as AssetKey }))}
|
||||
>
|
||||
<SelectTrigger id="pr-asset" className="w-full">
|
||||
<SelectValue>{ASSETS[v.assetKey].label}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ASSET_KEYS.map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{ASSETS[k].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.date)}>
|
||||
<FieldLabel htmlFor="pr-date">{t.date}</FieldLabel>
|
||||
<Input
|
||||
id="pr-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
max={todayIstanbul()}
|
||||
onChange={(e) => setV((s) => ({ ...s, date: e.target.value }))}
|
||||
/>
|
||||
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.buy)}>
|
||||
<FieldLabel htmlFor="pr-buy">{t.buy}</FieldLabel>
|
||||
<MoneyInput
|
||||
id="pr-buy"
|
||||
value={v.buy}
|
||||
onChange={(x) => setV((s) => ({ ...s, buy: x }))}
|
||||
autoFocus
|
||||
/>
|
||||
{errors.buy ? <FieldError>{errors.buy}</FieldError> : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pr-sell">{t.sell}</FieldLabel>
|
||||
<MoneyInput
|
||||
id="pr-sell"
|
||||
value={v.sell}
|
||||
onChange={(x) => setV((s) => ({ ...s, sell: x }))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<FieldDescription className="-mt-2">{t.sellHint}</FieldDescription>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { listAccounts } from "@/features/accounts/service";
|
||||
import { createContact, listContacts } from "@/features/contacts/service";
|
||||
import { listTransactions, setTransactionDeleted } from "@/features/transactions/service";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { AssetPriceModel } from "@/models/asset-price";
|
||||
import { AuditLogModel } from "@/models/audit-log";
|
||||
import { InKindDebtModel } from "@/models/in-kind-debt";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { addDays, addMonths } from "@/shared/recurrence";
|
||||
import { contactFormSchema } from "@/shared/schemas/contact";
|
||||
import {
|
||||
assetPriceFormSchema,
|
||||
contributionSchema,
|
||||
inKindFormSchema,
|
||||
paymentFormSchema,
|
||||
} from "@/shared/schemas/in-kind";
|
||||
import { transactionFilterSchema } from "@/shared/schemas/transaction";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import {
|
||||
addContribution,
|
||||
createInKind,
|
||||
listInKind,
|
||||
listPrices,
|
||||
recordPayment,
|
||||
savePrice,
|
||||
} from "./service";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||
let cakir = "";
|
||||
let ayse = "";
|
||||
let emre = "";
|
||||
let zeynep = "";
|
||||
const today = todayIstanbul();
|
||||
const monthAgo = addDays(today, -30);
|
||||
|
||||
beforeAll(async () => {
|
||||
ids = await setupSeededDb();
|
||||
cakir = ids.household("Çakır Ailesi");
|
||||
ayse = ids.user("ayse");
|
||||
emre = ids.user("emre");
|
||||
zeynep = ids.user("zeynep");
|
||||
// Seed örnek fiyatları bu testin fiyat senaryolarını etkilemesin.
|
||||
await AssetPriceModel.deleteMany({});
|
||||
});
|
||||
afterAll(teardownDb);
|
||||
|
||||
const acc = (name: string) =>
|
||||
AccountModel.findOne({ householdId: cakir, name })
|
||||
.lean()
|
||||
.then((a) => String(a!._id));
|
||||
async function balance(accountId: string) {
|
||||
return (await listAccounts(ayse, cakir))!.accounts.find((a) => a.id === accountId)!.balanceMinor!;
|
||||
}
|
||||
const price = (assetKey: string, date: string, buy: string, sell = "") =>
|
||||
assetPriceFormSchema.parse({ assetKey, date, buy, sell });
|
||||
async function contact(name: string, isPrivate = false) {
|
||||
const r = await createContact(
|
||||
ayse,
|
||||
cakir,
|
||||
contactFormSchema.parse({ name, openingDate: monthAgo, isPrivate }),
|
||||
);
|
||||
return r.ok ? r.data.id : "";
|
||||
}
|
||||
async function view(id: string) {
|
||||
return (await listInKind(ayse, cakir))!.find((v) => v.id === id)!;
|
||||
}
|
||||
|
||||
describe("fiyat tablosu", () => {
|
||||
it("elle fiyat; tek fiyat ikisine yazılır; gelecek tarih reddedilir; izleyici yazamaz", async () => {
|
||||
expect((await savePrice(ayse, cakir, price("gram_gold", today, "3.200", "3.300"))).ok).toBe(
|
||||
true,
|
||||
);
|
||||
expect((await savePrice(ayse, cakir, price("usd", today, "41,50"))).ok).toBe(true);
|
||||
expect((await savePrice(ayse, cakir, price("usd", addDays(today, 1), "1"))).ok).toBe(false);
|
||||
expect((await savePrice(zeynep, cakir, price("usd", today, "1"))).ok).toBe(false);
|
||||
const rows = (await listPrices(ayse, cakir))!.rows;
|
||||
expect(rows.find((r) => r.assetKey === "usd")!.latest).toMatchObject({
|
||||
buyMinor: 41_50,
|
||||
sellMinor: 41_50,
|
||||
});
|
||||
// Aynı gün yeniden giriş günceller.
|
||||
await savePrice(ayse, cakir, price("usd", today, "42"));
|
||||
expect(
|
||||
(await listPrices(ayse, cakir))!.rows.find((r) => r.assetKey === "usd")!.history,
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aynî borç", () => {
|
||||
let gold = "";
|
||||
let ahmet = "";
|
||||
|
||||
it("borç tarihindeki fiyat tablodan; yoksa birim fiyat istenir; değer satış fiyatıyla", async () => {
|
||||
ahmet = await contact("Altın Ahmet");
|
||||
const input = inKindFormSchema.parse({
|
||||
direction: "debt",
|
||||
assetKey: "gram_gold",
|
||||
quantity: "5",
|
||||
date: monthAgo,
|
||||
savingsAccountId: await acc("Birikim (Vadeli)"),
|
||||
monthlyPlan: "2.000",
|
||||
});
|
||||
expect((await createInKind(ayse, cakir, ahmet, input)).ok).toBe(false);
|
||||
await savePrice(ayse, cakir, price("gram_gold", monthAgo, "2.900", "3.000"));
|
||||
const r = await createInKind(ayse, cakir, ahmet, input);
|
||||
expect(r.ok).toBe(true);
|
||||
gold = r.ok ? r.data.id : "";
|
||||
const v = await view(gold);
|
||||
expect(v).toMatchObject({
|
||||
unitPriceMinor: 3_000_00,
|
||||
valueMinor: 15_000_00,
|
||||
label: "5 gr Gram altın (24 ayar)",
|
||||
});
|
||||
// Güncel: 5 × 3.300 (satış) = 16.500; birikim 0; ayda 2.000 → 9 ay.
|
||||
expect(v.progress).toMatchObject({
|
||||
currentValueMinor: 16_500_00,
|
||||
accumulatedMinor: 0,
|
||||
remainingNeedMinor: 16_500_00,
|
||||
estimatedCloseMonth: addMonths(`${today.slice(0, 7)}-01`, 9).slice(0, 7),
|
||||
});
|
||||
});
|
||||
|
||||
it("birikim katkısı: hesaptan birikim hesabına transfer; borç kapanmaz; cari TL bakiyesi ve gelir/gider değişmez", async () => {
|
||||
const ziraat = await acc("Ziraat Maaş");
|
||||
const birikim = await acc("Birikim (Vadeli)");
|
||||
const [z0, b0] = [await balance(ziraat), await balance(birikim)];
|
||||
const sums0 = (await listTransactions(ayse, cakir, transactionFilterSchema.parse({})))!.sums;
|
||||
const r = await addContribution(
|
||||
ayse,
|
||||
cakir,
|
||||
gold,
|
||||
contributionSchema.parse({ fromAccountId: ziraat, amount: "6.000", date: today }),
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(await balance(ziraat)).toBe(z0 - 6_000_00);
|
||||
expect(await balance(birikim)).toBe(b0 + 6_000_00);
|
||||
const v = await view(gold);
|
||||
expect(v.progress.accumulatedMinor).toBe(6_000_00);
|
||||
expect(v.progress.remainingNeedMinor).toBe(10_500_00);
|
||||
expect(v.closedAt).toBeNull();
|
||||
expect(
|
||||
(await listContacts(ayse, cakir))!.contacts.find((c) => c.id === ahmet)!.balanceMinor,
|
||||
).toBe(0);
|
||||
expect((await listTransactions(ayse, cakir, transactionFilterSchema.parse({})))!.sums).toEqual(
|
||||
sums0,
|
||||
);
|
||||
});
|
||||
|
||||
it("TL karşılığıyla kısmi ödeme: miktar güncel fiyattan; birikim hesabından çıkış", async () => {
|
||||
const birikim = await acc("Birikim (Vadeli)");
|
||||
const b0 = await balance(birikim);
|
||||
const r = await recordPayment(
|
||||
ayse,
|
||||
cakir,
|
||||
gold,
|
||||
paymentFormSchema.parse({ method: "cash", date: today, amount: "6.600", accountId: birikim }),
|
||||
);
|
||||
expect(r).toMatchObject({ ok: true, data: { closed: false } });
|
||||
const v = await view(gold);
|
||||
expect(v.paidQuantity).toBe(2_000);
|
||||
expect(v.progress.remainingQuantity).toBe(3_000);
|
||||
expect(v.progress.accumulatedMinor).toBe(0);
|
||||
expect(await balance(birikim)).toBe(b0 - 6_600_00);
|
||||
// Ödeme işlemi tek başına silinemez.
|
||||
const doc = (await InKindDebtModel.findById(gold).lean())!;
|
||||
expect(
|
||||
(await setTransactionDeleted(ayse, cakir, String(doc.payments[0]!.transactionId), true)).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("aynı varlıkla ödeme kalanı aşarsa kalana sınırlanır ve kayıt kapanır", async () => {
|
||||
const r = await recordPayment(
|
||||
ayse,
|
||||
cakir,
|
||||
gold,
|
||||
paymentFormSchema.parse({ method: "asset", date: today, quantity: "5" }),
|
||||
);
|
||||
expect(r).toMatchObject({ ok: true, data: { closed: true } });
|
||||
const v = await view(gold);
|
||||
expect(v.paidQuantity).toBe(5_000);
|
||||
expect(v.closedAt).toBe(today);
|
||||
expect(await AuditLogModel.exists({ action: "inkind.close", targetId: gold })).toBeTruthy();
|
||||
expect(
|
||||
(
|
||||
await recordPayment(
|
||||
ayse,
|
||||
cakir,
|
||||
gold,
|
||||
paymentFormSchema.parse({ method: "asset", date: today, quantity: "1" }),
|
||||
)
|
||||
).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("aynî alacak: alış fiyatıyla değerlenir, tahsilat hesaba girer; birikim planı alınmaz", async () => {
|
||||
const elif = await contact("Döviz Elif");
|
||||
const cuzdan = await acc("Cüzdan");
|
||||
expect(
|
||||
(
|
||||
await createInKind(
|
||||
ayse,
|
||||
cakir,
|
||||
elif,
|
||||
inKindFormSchema.parse({
|
||||
direction: "receivable",
|
||||
assetKey: "usd",
|
||||
quantity: "100",
|
||||
date: today,
|
||||
savingsAccountId: await acc("Birikim (Vadeli)"),
|
||||
}),
|
||||
)
|
||||
).ok,
|
||||
).toBe(false);
|
||||
const r = await createInKind(
|
||||
ayse,
|
||||
cakir,
|
||||
elif,
|
||||
inKindFormSchema.parse({
|
||||
direction: "receivable",
|
||||
assetKey: "usd",
|
||||
quantity: "100",
|
||||
date: today,
|
||||
}),
|
||||
);
|
||||
const id = r.ok ? r.data.id : "";
|
||||
expect((await view(id)).progress.currentValueMinor).toBe(4_200_00);
|
||||
const c0 = await balance(cuzdan);
|
||||
await recordPayment(
|
||||
ayse,
|
||||
cakir,
|
||||
id,
|
||||
paymentFormSchema.parse({ method: "cash", date: today, amount: "2.100", accountId: cuzdan }),
|
||||
);
|
||||
expect(await balance(cuzdan)).toBe(c0 + 2_100_00);
|
||||
expect((await view(id)).paidQuantity).toBe(50_000);
|
||||
});
|
||||
|
||||
it("gizli cari: aynî kayıt başkasına görünmez ve eklenemez", async () => {
|
||||
const secret = await contact("Gizli Altın", true);
|
||||
await createInKind(
|
||||
ayse,
|
||||
cakir,
|
||||
secret,
|
||||
inKindFormSchema.parse({
|
||||
direction: "debt",
|
||||
assetKey: "gram_gold",
|
||||
quantity: "1",
|
||||
date: today,
|
||||
}),
|
||||
);
|
||||
expect((await listInKind(emre, cakir))!.some((v) => v.contactId === secret)).toBe(false);
|
||||
expect(await listInKind(emre, cakir, secret)).toBeNull();
|
||||
expect(
|
||||
(
|
||||
await createInKind(
|
||||
emre,
|
||||
cakir,
|
||||
secret,
|
||||
inKindFormSchema.parse({
|
||||
direction: "debt",
|
||||
assetKey: "gram_gold",
|
||||
quantity: "1",
|
||||
date: today,
|
||||
}),
|
||||
)
|
||||
).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,573 @@
|
||||
import "server-only";
|
||||
import mongoose, { Types } from "mongoose";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { loadContext, type Ctx } from "@/features/transactions/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
import { toObjectId } from "@/lib/object-id";
|
||||
import { AssetPriceModel } from "@/models/asset-price";
|
||||
import { InKindDebtModel } from "@/models/in-kind-debt";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import {
|
||||
ASSET_KEYS,
|
||||
formatAssetQuantity,
|
||||
inKindProgress,
|
||||
quantityForAmount,
|
||||
valuationPrice,
|
||||
valueOf,
|
||||
type AssetKey,
|
||||
type InKindDirection,
|
||||
type InKindProgress,
|
||||
} from "@/shared/assets";
|
||||
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||
import type {
|
||||
AssetPriceForm,
|
||||
ContributionForm,
|
||||
InKindForm,
|
||||
PaymentForm,
|
||||
} from "@/shared/schemas/in-kind";
|
||||
import { foldTr } from "@/shared/search";
|
||||
|
||||
/**
|
||||
* Aynî borç/alacak ve fiyat tablosu (Bölüm 6.5–6.6). Okuma: viewer; yazma: member.
|
||||
* Görünürlük cariye bağlıdır ("yalnızca ben" cari → kayıt da gizli).
|
||||
*/
|
||||
|
||||
const t = tr.inKind;
|
||||
|
||||
// --- Fiyat tablosu ---
|
||||
|
||||
export type PriceQuote = { buyMinor: number; sellMinor: number; date: string };
|
||||
|
||||
/** Varlığın verilen tarihteki (veya öncesindeki en yakın) fiyatı. */
|
||||
export async function priceOn(
|
||||
householdId: Types.ObjectId,
|
||||
assetKey: AssetKey,
|
||||
date: string,
|
||||
): Promise<PriceQuote | null> {
|
||||
const p = await AssetPriceModel.findOne({ householdId, assetKey, date: { $lte: date } })
|
||||
.sort({ date: -1 })
|
||||
.lean();
|
||||
return p ? { buyMinor: p.buyMinor, sellMinor: p.sellMinor, date: p.date } : null;
|
||||
}
|
||||
|
||||
export type PriceRow = { assetKey: AssetKey; latest: PriceQuote | null; history: PriceQuote[] };
|
||||
|
||||
export async function listPrices(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
): Promise<{ rows: PriceRow[]; canWrite: boolean } | null> {
|
||||
const ctx = await loadContext(userId, householdId, "viewer");
|
||||
if (!ctx) return null;
|
||||
const all = await AssetPriceModel.find({ householdId: ctx.m.householdId })
|
||||
.sort({ date: -1 })
|
||||
.limit(1000)
|
||||
.lean();
|
||||
const rows = ASSET_KEYS.map((assetKey): PriceRow => {
|
||||
const history = all
|
||||
.filter((p) => p.assetKey === assetKey)
|
||||
.slice(0, 10)
|
||||
.map((p) => ({ buyMinor: p.buyMinor, sellMinor: p.sellMinor, date: p.date }));
|
||||
return { assetKey, latest: history[0] ?? null, history };
|
||||
});
|
||||
return { rows, canWrite: hasHouseholdRole(ctx.m.role, "member") };
|
||||
}
|
||||
|
||||
export async function savePrice(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
input: AssetPriceForm,
|
||||
): Promise<ActionResult> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
if (input.date > todayIstanbul()) return fail(tr.validation.date, { date: tr.validation.date });
|
||||
await AssetPriceModel.updateOne(
|
||||
{ householdId: ctx.m.householdId, assetKey: input.assetKey, date: input.date },
|
||||
{
|
||||
$set: { buyMinor: input.buyMinor, sellMinor: input.sellMinor, source: "manual" },
|
||||
$setOnInsert: { createdBy: new Types.ObjectId(actorId) },
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
// --- Aynî kayıtlar ---
|
||||
|
||||
type PaymentDoc = {
|
||||
_id: Types.ObjectId;
|
||||
date: string;
|
||||
method: "asset" | "cash";
|
||||
quantity: number;
|
||||
amountMinor: number;
|
||||
accountId?: Types.ObjectId | null;
|
||||
transactionId?: Types.ObjectId | null;
|
||||
note: string;
|
||||
};
|
||||
|
||||
type DebtDoc = {
|
||||
_id: Types.ObjectId;
|
||||
householdId: Types.ObjectId;
|
||||
contactId: Types.ObjectId;
|
||||
direction: InKindDirection;
|
||||
assetKey: AssetKey;
|
||||
quantity: number;
|
||||
date: string;
|
||||
unitPriceMinor?: number | null;
|
||||
valueMinor?: number | null;
|
||||
note: string;
|
||||
savingsAccountId?: Types.ObjectId | null;
|
||||
monthlyPlanMinor?: number | null;
|
||||
payments: PaymentDoc[];
|
||||
closedAt?: string | null;
|
||||
};
|
||||
|
||||
export type InKindView = {
|
||||
id: string;
|
||||
contactId: string;
|
||||
contactName: string;
|
||||
direction: InKindDirection;
|
||||
assetKey: AssetKey;
|
||||
label: string;
|
||||
quantity: number;
|
||||
paidQuantity: number;
|
||||
date: string;
|
||||
unitPriceMinor: number | null;
|
||||
valueMinor: number | null;
|
||||
note: string;
|
||||
savingsAccount: { id: string; name: string } | null;
|
||||
monthlyPlanMinor: number | null;
|
||||
closedAt: string | null;
|
||||
currentPrice: PriceQuote | null;
|
||||
progress: InKindProgress;
|
||||
payments: {
|
||||
id: string;
|
||||
date: string;
|
||||
method: "asset" | "cash";
|
||||
quantity: number;
|
||||
amountMinor: number;
|
||||
account: string | null;
|
||||
note: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
function contactVisible(ctx: Ctx, contactId: Types.ObjectId | string) {
|
||||
const c = ctx.contactMap.get(String(contactId));
|
||||
return c && !c.hidden ? c : null;
|
||||
}
|
||||
|
||||
/** Birikim: borca bağlı katkılar (birikim hesabına giren) − birikim hesabından yapılan ödemeler. */
|
||||
async function savingsFlows(debtIds: Types.ObjectId[]) {
|
||||
const rows = await TransactionModel.aggregate<{
|
||||
_id: { debt: Types.ObjectId; role: string };
|
||||
sum: number;
|
||||
}>([
|
||||
{
|
||||
$match: {
|
||||
inKindDebtId: { $in: debtIds },
|
||||
status: "posted",
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: { debt: "$inKindDebtId", role: "$inKindRole" },
|
||||
sum: { $sum: "$amountMinor" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
const map = new Map<string, { contributed: number }>();
|
||||
for (const r of rows) {
|
||||
if (r._id.role !== "contribution") continue;
|
||||
map.set(String(r._id.debt), { contributed: r.sum });
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function toViews(ctx: Ctx, docs: DebtDoc[]): Promise<InKindView[]> {
|
||||
const today = todayIstanbul();
|
||||
const flows = await savingsFlows(docs.map((d) => d._id));
|
||||
const priceCache = new Map<string, PriceQuote | null>();
|
||||
const views: InKindView[] = [];
|
||||
for (const d of docs) {
|
||||
if (!priceCache.has(d.assetKey))
|
||||
priceCache.set(d.assetKey, await priceOn(ctx.m.householdId, d.assetKey, today));
|
||||
const price = priceCache.get(d.assetKey)!;
|
||||
const paidQuantity = d.payments.reduce((a, p) => a + p.quantity, 0);
|
||||
const savingsId = d.savingsAccountId ? String(d.savingsAccountId) : null;
|
||||
// Birikim hesabından yapılan ödemeler birikimden düşer.
|
||||
const spentMinor = d.payments
|
||||
.filter((p) => savingsId && String(p.accountId) === savingsId)
|
||||
.reduce((a, p) => a + p.amountMinor, 0);
|
||||
const savings = savingsId ? ctx.accountMap.get(savingsId) : undefined;
|
||||
views.push({
|
||||
id: String(d._id),
|
||||
contactId: String(d.contactId),
|
||||
contactName: ctx.contactMap.get(String(d.contactId))?.name ?? "—",
|
||||
direction: d.direction,
|
||||
assetKey: d.assetKey,
|
||||
label: formatAssetQuantity(d.assetKey, d.quantity),
|
||||
quantity: d.quantity,
|
||||
paidQuantity,
|
||||
date: d.date,
|
||||
unitPriceMinor: d.unitPriceMinor ?? null,
|
||||
valueMinor: d.valueMinor ?? null,
|
||||
note: d.note,
|
||||
savingsAccount: savings && !savings.hidden ? { id: savingsId!, name: savings.name } : null,
|
||||
monthlyPlanMinor: d.monthlyPlanMinor ?? null,
|
||||
closedAt: d.closedAt ?? null,
|
||||
currentPrice: price,
|
||||
progress: inKindProgress({
|
||||
quantity: d.quantity,
|
||||
paidQuantity,
|
||||
unitPriceMinor: valuationPrice(d.direction, price),
|
||||
contributedMinor: flows.get(String(d._id))?.contributed ?? 0,
|
||||
spentMinor,
|
||||
monthlyPlanMinor: d.direction === "debt" ? (d.monthlyPlanMinor ?? null) : null,
|
||||
today,
|
||||
}),
|
||||
payments: d.payments.map((p) => {
|
||||
const a = p.accountId ? ctx.accountMap.get(String(p.accountId)) : undefined;
|
||||
return {
|
||||
id: String(p._id),
|
||||
date: p.date,
|
||||
method: p.method,
|
||||
quantity: p.quantity,
|
||||
amountMinor: p.amountMinor,
|
||||
account: a ? (a.hidden ? tr.transactions.list.hiddenAccount : a.name) : null,
|
||||
note: p.note,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
/** Bir carinin (veya tüm görünür carilerin) aynî kayıtları; açıklar önce. */
|
||||
export async function listInKind(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
contactId?: string,
|
||||
): Promise<InKindView[] | null> {
|
||||
const ctx = await loadContext(userId, householdId, "viewer");
|
||||
if (!ctx) return null;
|
||||
const filter: Record<string, unknown> = { householdId: ctx.m.householdId, deletedAt: null };
|
||||
if (contactId) {
|
||||
const id = toObjectId(contactId);
|
||||
if (!id || !contactVisible(ctx, id)) return null;
|
||||
filter.contactId = id;
|
||||
} else {
|
||||
filter.contactId = { $nin: ctx.hiddenContactIds };
|
||||
}
|
||||
const docs = await InKindDebtModel.find(filter).sort({ closedAt: 1, date: -1 }).lean<DebtDoc[]>();
|
||||
return toViews(
|
||||
ctx,
|
||||
docs.filter((d) => contactVisible(ctx, d.contactId)),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadDebt(ctx: Ctx, debtId: string) {
|
||||
const id = toObjectId(debtId);
|
||||
if (!id) return null;
|
||||
const d = await InKindDebtModel.findOne({
|
||||
_id: id,
|
||||
householdId: ctx.m.householdId,
|
||||
deletedAt: null,
|
||||
}).lean<DebtDoc>();
|
||||
return d && contactVisible(ctx, d.contactId) ? d : null;
|
||||
}
|
||||
|
||||
function checkAccount(ctx: Ctx, id: string | null) {
|
||||
if (!id) return null;
|
||||
const a = ctx.accountMap.get(id);
|
||||
return a && !a.hidden && !a.archived ? a : null;
|
||||
}
|
||||
|
||||
async function debtFields(ctx: Ctx, input: InKindForm) {
|
||||
let unitPriceMinor = input.unitPrice;
|
||||
if (unitPriceMinor === null) {
|
||||
const p = await priceOn(ctx.m.householdId, input.assetKey, input.date);
|
||||
unitPriceMinor = valuationPrice(input.direction, p);
|
||||
}
|
||||
if (unitPriceMinor === null) return fail(t.errors.noPrice, { unitPrice: t.errors.noPrice });
|
||||
if (input.savingsAccountId && input.direction !== "debt")
|
||||
return fail(t.errors.receivablePlan, { savingsAccountId: t.errors.receivablePlan });
|
||||
const savings = input.savingsAccountId ? checkAccount(ctx, input.savingsAccountId) : null;
|
||||
if (input.savingsAccountId && !savings)
|
||||
return fail(tr.transactions.validation.accountInvalid, {
|
||||
savingsAccountId: tr.transactions.validation.accountInvalid,
|
||||
});
|
||||
return ok({
|
||||
direction: input.direction,
|
||||
assetKey: input.assetKey,
|
||||
quantity: input.quantity,
|
||||
date: input.date,
|
||||
unitPriceMinor,
|
||||
valueMinor: valueOf(input.quantity, unitPriceMinor),
|
||||
note: input.note,
|
||||
savingsAccountId: savings?.id ?? null,
|
||||
monthlyPlanMinor: savings ? input.monthlyPlan : null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createInKind(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
contactId: string,
|
||||
input: InKindForm,
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const cid = toObjectId(contactId);
|
||||
const contact = cid ? contactVisible(ctx, cid) : null;
|
||||
if (!contact) return fail(tr.common.notFound);
|
||||
if (contact.archived) return fail(tr.contacts.errors.archived);
|
||||
const fields = await debtFields(ctx, input);
|
||||
if (!fields.ok) return fields;
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
const doc = await InKindDebtModel.create({
|
||||
...fields.data,
|
||||
householdId: ctx.m.householdId,
|
||||
contactId: contact.id,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
return ok({ id: String(doc._id) });
|
||||
}
|
||||
|
||||
export async function updateInKind(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
debtId: string,
|
||||
input: InKindForm,
|
||||
): Promise<ActionResult> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const d = await loadDebt(ctx, debtId);
|
||||
if (!d) return fail(tr.common.notFound);
|
||||
const paid = d.payments.reduce((a, p) => a + p.quantity, 0);
|
||||
if (input.quantity < paid)
|
||||
return fail(t.errors.quantityBelowPaid, { quantity: t.errors.quantityBelowPaid });
|
||||
// Ödeme varsa yön ve varlık değişemez (ödemeler o varlık cinsindendir).
|
||||
if (d.payments.length && (input.direction !== d.direction || input.assetKey !== d.assetKey))
|
||||
return fail(t.errors.closed);
|
||||
const fields = await debtFields(ctx, input);
|
||||
if (!fields.ok) return fields;
|
||||
await InKindDebtModel.updateOne(
|
||||
{ _id: d._id, householdId: ctx.m.householdId },
|
||||
{
|
||||
$set: {
|
||||
...fields.data,
|
||||
closedAt: paid >= input.quantity ? (d.closedAt ?? todayIstanbul()) : null,
|
||||
updatedBy: new Types.ObjectId(actorId),
|
||||
},
|
||||
},
|
||||
);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
export async function deleteInKind(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
debtId: string,
|
||||
): Promise<ActionResult> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const d = await loadDebt(ctx, debtId);
|
||||
if (!d) return fail(tr.common.notFound);
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
await InKindDebtModel.updateOne(
|
||||
{ _id: d._id },
|
||||
{ $set: { deletedAt: new Date(), updatedBy: actor } },
|
||||
);
|
||||
await writeAudit({
|
||||
action: "inkind.delete",
|
||||
actorId: actor,
|
||||
householdId: ctx.m.householdId,
|
||||
targetType: "inKindDebt",
|
||||
targetId: String(d._id),
|
||||
diff: { assetKey: d.assetKey, quantity: d.quantity, direction: d.direction },
|
||||
});
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
// --- Birikim ve ödeme ---
|
||||
|
||||
/** Birikim katkısı: kaynak hesaptan borcun birikim hesabına transfer (borç kapanmaz). */
|
||||
export async function addContribution(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
debtId: string,
|
||||
input: ContributionForm,
|
||||
): Promise<ActionResult<{ transactionId: string }>> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const d = await loadDebt(ctx, debtId);
|
||||
if (!d) return fail(tr.common.notFound);
|
||||
if (d.closedAt) return fail(t.errors.closed);
|
||||
if (d.direction !== "debt" || !d.savingsAccountId) return fail(t.errors.noSavings);
|
||||
const from = checkAccount(ctx, input.fromAccountId);
|
||||
const to = checkAccount(ctx, String(d.savingsAccountId));
|
||||
if (!from || !to)
|
||||
return fail(tr.transactions.validation.accountInvalid, {
|
||||
fromAccountId: tr.transactions.validation.accountInvalid,
|
||||
});
|
||||
if (from.id.equals(to.id))
|
||||
return fail(t.errors.sameAccount, { fromAccountId: t.errors.sameAccount });
|
||||
const date = istanbulDayStart(input.date);
|
||||
if (!date) return fail(tr.validation.date, { date: tr.validation.date });
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
const contactName = ctx.contactMap.get(String(d.contactId))?.name ?? "";
|
||||
const description = t.contributionDescription(
|
||||
formatAssetQuantity(d.assetKey, d.quantity),
|
||||
contactName,
|
||||
);
|
||||
const tx = await TransactionModel.create({
|
||||
householdId: ctx.m.householdId,
|
||||
type: "transfer",
|
||||
status: "posted",
|
||||
date,
|
||||
accountId: from.id,
|
||||
toAccountId: to.id,
|
||||
amountMinor: input.amount,
|
||||
description,
|
||||
searchText: foldTr(description),
|
||||
contactId: d.contactId,
|
||||
inKindDebtId: d._id,
|
||||
inKindRole: "contribution",
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
return ok({ transactionId: String(tx._id) });
|
||||
}
|
||||
|
||||
/**
|
||||
* "Ödeme gerçekleşti": kısmi ödeme miktarı düşürür, tam ödemede kayıt kapanır. TL tutar varsa
|
||||
* hesaptan çıkış (borç) veya hesaba giriş (alacak) işlemi aynı transaction içinde oluşturulur.
|
||||
*/
|
||||
export async function recordPayment(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
debtId: string,
|
||||
input: PaymentForm,
|
||||
): Promise<ActionResult<{ closed: boolean }>> {
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const d = await loadDebt(ctx, debtId);
|
||||
if (!d) return fail(tr.common.notFound);
|
||||
if (d.closedAt) return fail(t.errors.closed);
|
||||
if (input.date > todayIstanbul()) return fail(tr.validation.date, { date: tr.validation.date });
|
||||
const date = istanbulDayStart(input.date);
|
||||
if (!date) return fail(tr.validation.date, { date: tr.validation.date });
|
||||
|
||||
const account = input.accountId ? checkAccount(ctx, input.accountId) : null;
|
||||
if (input.accountId && !account)
|
||||
return fail(tr.transactions.validation.accountInvalid, {
|
||||
accountId: tr.transactions.validation.accountInvalid,
|
||||
});
|
||||
|
||||
let quantity = input.quantity;
|
||||
if (quantity === null) {
|
||||
const price = valuationPrice(
|
||||
d.direction,
|
||||
await priceOn(ctx.m.householdId, d.assetKey, input.date),
|
||||
);
|
||||
if (!price) return fail(t.errors.noCurrentPrice, { quantity: t.errors.noCurrentPrice });
|
||||
quantity = quantityForAmount(input.amount ?? 0, price);
|
||||
}
|
||||
const paid = d.payments.reduce((a, p) => a + p.quantity, 0);
|
||||
const remaining = d.quantity - paid;
|
||||
quantity = Math.min(quantity, remaining);
|
||||
if (quantity <= 0) return fail(t.validation.quantity, { quantity: t.validation.quantity });
|
||||
const closed = paid + quantity >= d.quantity;
|
||||
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
const amountMinor = input.amount ?? 0;
|
||||
const contactName = ctx.contactMap.get(String(d.contactId))?.name ?? "";
|
||||
const description = t.paymentDescription(
|
||||
d.direction === "debt",
|
||||
formatAssetQuantity(d.assetKey, quantity),
|
||||
contactName,
|
||||
);
|
||||
|
||||
await mongoose.connection.transaction(async (session) => {
|
||||
let transactionId: Types.ObjectId | null = null;
|
||||
if (amountMinor > 0 && account) {
|
||||
const out = d.direction === "debt";
|
||||
const [tx] = await TransactionModel.create(
|
||||
[
|
||||
{
|
||||
householdId: ctx.m.householdId,
|
||||
type: "transfer",
|
||||
status: "posted",
|
||||
date,
|
||||
// Borç ödemesi hesaptan çıkar; alacak tahsilatı hesaba girer.
|
||||
accountId: out ? account.id : null,
|
||||
toAccountId: out ? null : account.id,
|
||||
amountMinor,
|
||||
description,
|
||||
searchText: foldTr(description),
|
||||
contactId: d.contactId,
|
||||
inKindDebtId: d._id,
|
||||
inKindRole: "payment",
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
},
|
||||
],
|
||||
{ session },
|
||||
);
|
||||
transactionId = tx!._id;
|
||||
}
|
||||
await InKindDebtModel.updateOne(
|
||||
{ _id: d._id, householdId: ctx.m.householdId, closedAt: null },
|
||||
{
|
||||
$push: {
|
||||
payments: {
|
||||
date: input.date,
|
||||
method: input.method,
|
||||
quantity,
|
||||
amountMinor,
|
||||
accountId: account?.id ?? null,
|
||||
transactionId,
|
||||
note: input.note,
|
||||
createdBy: actor,
|
||||
},
|
||||
},
|
||||
$set: { closedAt: closed ? input.date : null, updatedBy: actor },
|
||||
},
|
||||
{ session },
|
||||
);
|
||||
await writeAudit(
|
||||
{
|
||||
action: closed ? "inkind.close" : "inkind.payment",
|
||||
actorId: actor,
|
||||
householdId: ctx.m.householdId,
|
||||
targetType: "inKindDebt",
|
||||
targetId: String(d._id),
|
||||
diff: { method: input.method, quantity, amountMinor, remaining: remaining - quantity },
|
||||
},
|
||||
session,
|
||||
);
|
||||
});
|
||||
return ok({ closed });
|
||||
}
|
||||
|
||||
/** Cari listesi için açık aynî kayıt özetleri (cari kimliği → etiketler). */
|
||||
export async function openInKindByContact(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
): Promise<Map<string, { direction: InKindDirection; label: string }[]>> {
|
||||
const views = (await listInKind(userId, householdId)) ?? [];
|
||||
const map = new Map<string, { direction: InKindDirection; label: string }[]>();
|
||||
for (const v of views) {
|
||||
if (v.closedAt) continue;
|
||||
const list = map.get(v.contactId) ?? [];
|
||||
list.push({
|
||||
direction: v.direction,
|
||||
label: formatAssetQuantity(v.assetKey, v.quantity - v.paidQuantity),
|
||||
});
|
||||
map.set(v.contactId, list);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -196,7 +196,7 @@ describe("kural ve planlı kayıtlar", () => {
|
||||
type: "expense",
|
||||
amount: 777_00,
|
||||
date: d.date,
|
||||
accountId: d.account.id,
|
||||
accountId: d.account!.id,
|
||||
toAccountId: null,
|
||||
categoryId: d.categoryId,
|
||||
splits: [],
|
||||
|
||||
@@ -376,7 +376,8 @@ export async function confirmOccurrence(
|
||||
ctx.m.householdId,
|
||||
tx.payeeName ?? "",
|
||||
tx.type,
|
||||
tx.accountId,
|
||||
// Planlı kayıtlar cari hareketi değildir; kaynak hesap her zaman dolu.
|
||||
tx.accountId!,
|
||||
tx.categoryId ?? null,
|
||||
);
|
||||
await TransactionModel.updateOne(
|
||||
|
||||
@@ -102,7 +102,7 @@ function toValues(mode: TransactionFormMode, options: TransactionFormOptions | n
|
||||
type: x.type,
|
||||
amount: formatMoneyInput(x.amountMinor),
|
||||
date: mode.kind === "copy" ? todayIstanbul() : x.date,
|
||||
accountId: x.account.id,
|
||||
accountId: x.account?.id ?? "",
|
||||
toAccountId: x.toAccount?.id ?? "",
|
||||
categoryId: x.categoryId ?? "",
|
||||
splits: x.splits.map((s) => ({
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeftRight, CheckCheck, Copy, Pencil, Scale, Split, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
Users,
|
||||
CheckCheck,
|
||||
Copy,
|
||||
Pencil,
|
||||
Scale,
|
||||
Split,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { IconBadge } from "@/components/appearance/swatch";
|
||||
@@ -83,6 +93,13 @@ export function RowIcon({ item }: { item: TransactionListItem }) {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (item.contact) {
|
||||
return (
|
||||
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Users className="size-4.5" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (item.isAdjustment) {
|
||||
return (
|
||||
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
@@ -112,6 +129,7 @@ export function rowTitle(item: TransactionListItem) {
|
||||
|
||||
function baseTitle(item: TransactionListItem) {
|
||||
if (item.isAdjustment) return t.adjustment;
|
||||
if (item.contact) return item.contact.name;
|
||||
if (item.payeeName) return item.payeeName;
|
||||
if (item.description) return item.description;
|
||||
if (item.type === "transfer") return t.types.transfer;
|
||||
@@ -119,14 +137,18 @@ function baseTitle(item: TransactionListItem) {
|
||||
}
|
||||
|
||||
export function rowSubtitle(item: TransactionListItem) {
|
||||
if (item.contact) {
|
||||
const account = item.account?.name ?? item.toAccount?.name ?? "—";
|
||||
return `${tr.contacts.kinds[item.contact.kind]} · ${account}`;
|
||||
}
|
||||
if (item.type === "transfer")
|
||||
return t.list.transfer(item.account.name, item.toAccount?.name ?? "—");
|
||||
return t.list.transfer(item.account?.name ?? "—", item.toAccount?.name ?? "—");
|
||||
const what = item.isAdjustment
|
||||
? item.description
|
||||
: item.splitCount
|
||||
? t.list.split(item.splitCount)
|
||||
: (item.category?.path ?? "—");
|
||||
return `${what} · ${item.account.name}`;
|
||||
return `${what} · ${item.account?.name ?? "—"}`;
|
||||
}
|
||||
|
||||
function DetailDialog({
|
||||
@@ -167,8 +189,15 @@ function DetailDialog({
|
||||
<Trash2 />
|
||||
{t.actions.delete}
|
||||
</Button>
|
||||
{/* Mutabakat düzeltmesi düzenlenemez/kopyalanamaz; yalnızca silinebilir. */}
|
||||
{detail.isAdjustment ? null : (
|
||||
{/* Mutabakat düzeltmesi düzenlenemez/kopyalanamaz; cari hareketi cari sayfasında. */}
|
||||
{detail.contact ? (
|
||||
<Button asChild>
|
||||
<Link href={`/cari/${detail.contact.id}`}>
|
||||
<Pencil />
|
||||
{tr.contacts.openContact}
|
||||
</Link>
|
||||
</Button>
|
||||
) : detail.isAdjustment ? null : (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => onCopy(detail)}>
|
||||
<Copy />
|
||||
@@ -202,10 +231,20 @@ function DetailDialog({
|
||||
<dl className="divide-y border-y">
|
||||
{row(t.filters.type, <Badge variant="secondary">{t.types[detail.type]}</Badge>)}
|
||||
{detail.status !== "posted" ? row(t.filters.status, t.statuses[detail.status]) : null}
|
||||
{row(
|
||||
detail.type === "transfer" ? t.fields.fromAccount : t.fields.account,
|
||||
detail.account.name,
|
||||
)}
|
||||
{detail.contact
|
||||
? row(
|
||||
tr.contacts.contact,
|
||||
<Link href={`/cari/${detail.contact.id}`} className="hover:underline">
|
||||
{detail.contact.name} · {tr.contacts.kinds[detail.contact.kind]}
|
||||
</Link>,
|
||||
)
|
||||
: null}
|
||||
{detail.account
|
||||
? row(
|
||||
detail.type === "transfer" ? t.fields.fromAccount : t.fields.account,
|
||||
detail.account.name,
|
||||
)
|
||||
: null}
|
||||
{detail.toAccount ? row(t.fields.toAccount, detail.toAccount.name) : null}
|
||||
{detail.category ? row(t.fields.category, detail.category.path) : null}
|
||||
{detail.isAdjustment ? row(t.fields.category, t.adjustment) : null}
|
||||
|
||||
@@ -383,7 +383,7 @@ describe("filtreler", () => {
|
||||
);
|
||||
expect(byAccount!.total).toBeGreaterThan(0);
|
||||
expect(
|
||||
byAccount!.items.every((i) => i.toAccount?.id === cuzdan || i.account.id === cuzdan),
|
||||
byAccount!.items.every((i) => i.toAccount?.id === cuzdan || i.account!.id === cuzdan),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -13,10 +13,12 @@ import { writeAudit } from "@/lib/audit";
|
||||
import { getDb } from "@/lib/db/mongo";
|
||||
import { toObjectId } from "@/lib/object-id";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { ContactModel } from "@/models/contact";
|
||||
import { CategoryModel } from "@/models/category";
|
||||
import { PayeeModel } from "@/models/payee";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import type { AccountType } from "@/shared/accounts";
|
||||
import type { ContactEntryKind } from "@/shared/contacts";
|
||||
import { allowedAttachmentKinds } from "@/shared/attachments";
|
||||
import type { IconKey, Swatch } from "@/shared/appearance";
|
||||
import { installmentSchedule } from "@/shared/card-periods";
|
||||
@@ -68,7 +70,7 @@ export async function loadContext(
|
||||
const m = await resolveMembership(userId, householdId, minRole);
|
||||
if (!m) return null;
|
||||
await postDueInstallments(m.householdId);
|
||||
const [accounts, categories] = await Promise.all([
|
||||
const [accounts, categories, contacts] = await Promise.all([
|
||||
AccountModel.find({ householdId: m.householdId })
|
||||
.select({ name: 1, type: 1, archivedAt: 1, isPrivate: 1, ownerUserId: 1, icon: 1, color: 1 })
|
||||
.lean(),
|
||||
@@ -84,6 +86,9 @@ export async function loadContext(
|
||||
ancestors: 1,
|
||||
})
|
||||
.lean(),
|
||||
ContactModel.find({ householdId: m.householdId })
|
||||
.select({ name: 1, isPrivate: 1, ownerUserId: 1, archivedAt: 1 })
|
||||
.lean(),
|
||||
]);
|
||||
const accountMap = new Map<string, AccountInfo>(
|
||||
accounts.map((a) => [
|
||||
@@ -126,18 +131,39 @@ export async function loadContext(
|
||||
]),
|
||||
);
|
||||
const hiddenAccountIds = [...accountMap.values()].filter((a) => a.hidden).map((a) => a.id);
|
||||
return { m, accountMap, categoryMap, hiddenAccountIds, flatCategories: flat };
|
||||
const contactMap = new Map(
|
||||
contacts.map((c) => [
|
||||
String(c._id),
|
||||
{
|
||||
id: c._id,
|
||||
name: c.name,
|
||||
archived: Boolean(c.archivedAt),
|
||||
hidden: Boolean(c.isPrivate) && String(c.ownerUserId) !== userId,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const hiddenContactIds = [...contactMap.values()].filter((c) => c.hidden).map((c) => c.id);
|
||||
return {
|
||||
m,
|
||||
accountMap,
|
||||
categoryMap,
|
||||
contactMap,
|
||||
hiddenAccountIds,
|
||||
hiddenContactIds,
|
||||
flatCategories: flat,
|
||||
};
|
||||
}
|
||||
|
||||
export type Ctx = NonNullable<Awaited<ReturnType<typeof loadContext>>>;
|
||||
|
||||
/** Gizli hesaplara dokunan işlemleri dışlayan temel filtre. */
|
||||
/** Gizli hesaplara veya gizli carilere dokunan işlemleri dışlayan temel filtre. */
|
||||
export function baseFilter(ctx: Ctx): Record<string, unknown> {
|
||||
const f: Record<string, unknown> = { householdId: ctx.m.householdId, deletedAt: null };
|
||||
if (ctx.hiddenAccountIds.length) {
|
||||
f.accountId = { $nin: ctx.hiddenAccountIds };
|
||||
f.toAccountId = { $nin: ctx.hiddenAccountIds };
|
||||
}
|
||||
if (ctx.hiddenContactIds.length) f.contactId = { $nin: ctx.hiddenContactIds };
|
||||
return f;
|
||||
}
|
||||
|
||||
@@ -221,7 +247,7 @@ function checkCategory(
|
||||
}
|
||||
|
||||
type Existing = {
|
||||
accountId: Types.ObjectId;
|
||||
accountId?: Types.ObjectId | null;
|
||||
toAccountId?: Types.ObjectId | null;
|
||||
categoryId?: Types.ObjectId | null;
|
||||
splits?: { categoryId: Types.ObjectId }[];
|
||||
@@ -238,7 +264,7 @@ export function buildDoc(
|
||||
const accountId = checkAccount(
|
||||
ctx,
|
||||
input.accountId,
|
||||
existing ? String(existing.accountId) : null,
|
||||
existing?.accountId ? String(existing.accountId) : null,
|
||||
);
|
||||
if (!accountId)
|
||||
return fail(t.validation.accountInvalid, { accountId: t.validation.accountInvalid });
|
||||
@@ -445,6 +471,8 @@ export async function updateTransaction(
|
||||
const current = await loadOwnTransaction(ctx, transactionId);
|
||||
if (!current) return fail(tr.common.notFound);
|
||||
if (current.isAdjustment) return fail(t.adjustmentReadOnly);
|
||||
// Cari hareketi cari sayfasındaki formla düzenlenir (tür ve yön cariye özgü).
|
||||
if (current.contactId) return fail(t.contactEditHint);
|
||||
const built = buildDoc(ctx, input, current);
|
||||
if (!built.ok) return built;
|
||||
const d = built.data as {
|
||||
@@ -467,7 +495,7 @@ export async function updateTransaction(
|
||||
d.type !== current.type ||
|
||||
d.amountMinor !== current.amountMinor ||
|
||||
d.date.getTime() !== new Date(current.date).getTime() ||
|
||||
!d.accountId.equals(current.accountId) ||
|
||||
String(d.accountId) !== String(current.accountId ?? "") ||
|
||||
String(d.toAccountId ?? "") !== String(current.toAccountId ?? "");
|
||||
try {
|
||||
await mongoose.connection.transaction(async (session) => {
|
||||
@@ -504,6 +532,8 @@ export async function setTransactionDeleted(
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const current = await loadOwnTransaction(ctx, transactionId, { includeDeleted: true });
|
||||
if (!current || Boolean(current.deletedAt) === deleted) return fail(tr.common.notFound);
|
||||
// Aynî borç ödemesi kaydın ödeme geçmişinin parçasıdır; tek başına silinmez.
|
||||
if (current.inKindRole === "payment") return fail(t.inKindPaymentLocked);
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
await TransactionModel.updateOne(
|
||||
{ _id: current._id, householdId: ctx.m.householdId },
|
||||
@@ -567,8 +597,11 @@ export type TransactionListItem = {
|
||||
status: TransactionStatus;
|
||||
date: string;
|
||||
amountMinor: number;
|
||||
account: { id: string; name: string };
|
||||
/** Kaynak hesap; cariden girişte (borç alma, tahsilat) null. */
|
||||
account: { id: string; name: string } | null;
|
||||
toAccount: { id: string; name: string } | null;
|
||||
/** Cari transferi: kişi ve hareket türü. */
|
||||
contact: { id: string; name: string; kind: ContactEntryKind } | null;
|
||||
category: { id: string; name: string; path: string; icon: IconKey; color: Swatch } | null;
|
||||
splitCount: number;
|
||||
payeeName: string;
|
||||
@@ -629,7 +662,7 @@ export function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): Transa
|
||||
status: tx.status,
|
||||
date: toIstanbulYmd(tx.date),
|
||||
amountMinor: tx.amountMinor,
|
||||
account: acc(tx.accountId)!,
|
||||
account: acc(tx.accountId),
|
||||
toAccount: acc(tx.toAccountId),
|
||||
category: cat
|
||||
? { id: String(cat.id), name: cat.name, path: cat.path, icon: cat.icon, color: cat.color }
|
||||
@@ -642,6 +675,14 @@ export function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): Transa
|
||||
attachmentCount: tx.attachmentCount ?? 0,
|
||||
isAdjustment: Boolean(tx.isAdjustment),
|
||||
reconciledAccountIds: (tx.reconciledAccountIds ?? []).map(String),
|
||||
contact:
|
||||
tx.contactId && tx.contactKind
|
||||
? {
|
||||
id: String(tx.contactId),
|
||||
name: ctx.contactMap.get(String(tx.contactId))?.name ?? "—",
|
||||
kind: tx.contactKind,
|
||||
}
|
||||
: null,
|
||||
installment: tx.installment?.planId
|
||||
? {
|
||||
planId: String(tx.installment.planId),
|
||||
@@ -836,7 +877,11 @@ export async function exportTransactions(
|
||||
} else {
|
||||
out.push({
|
||||
...common,
|
||||
category: r.isAdjustment ? t.adjustment : categoryPath(r.categoryId),
|
||||
category: r.isAdjustment
|
||||
? t.adjustment
|
||||
: r.contactId && r.contactKind
|
||||
? `${tr.contacts.contact}: ${ctx.contactMap.get(String(r.contactId))?.name ?? ""} (${tr.contacts.kinds[r.contactKind]})`
|
||||
: categoryPath(r.categoryId),
|
||||
note: "",
|
||||
amountMinor: sign(r.type) * r.amountMinor,
|
||||
});
|
||||
|
||||
@@ -201,7 +201,15 @@ export async function getAccountStatement(
|
||||
const effects = rowsAsc.map((tx) => {
|
||||
const item = toItem(ctx, tx, names);
|
||||
const counterpart = tx.type === "transfer" ? [item.account, item.toAccount] : [];
|
||||
const restricted = counterpart.some((a) => a && ctx.accountMap.get(a.id)?.hidden);
|
||||
// Gizli cariye bağlı hareket (cari hareketi veya aynî borç ödemesi): kişi adı ve açıklama gizlenir.
|
||||
const hiddenContact = Boolean(tx.contactId && ctx.contactMap.get(String(tx.contactId))?.hidden);
|
||||
if (hiddenContact) {
|
||||
if (item.contact) item.contact.name = tr.transactions.list.hiddenContact;
|
||||
item.description = "";
|
||||
item.payeeName = tr.transactions.list.hiddenContact;
|
||||
}
|
||||
const restricted =
|
||||
hiddenContact || counterpart.some((a) => a && ctx.accountMap.get(a.id)?.hidden);
|
||||
if (restricted) {
|
||||
// Gizli karşı hesabın adı sızmasın.
|
||||
for (const a of counterpart) {
|
||||
|
||||
+183
@@ -298,6 +298,7 @@ export const tr = {
|
||||
total: (n: number) => `${n} işlem`,
|
||||
sums: { income: "Gelir", expense: "Gider", net: "Net" },
|
||||
hiddenAccount: "Gizli hesap",
|
||||
hiddenContact: "Gizli cari",
|
||||
},
|
||||
filters: {
|
||||
title: "Filtreler",
|
||||
@@ -361,6 +362,8 @@ export const tr = {
|
||||
"Mutabakat düzeltmesi düzenlenemez; gerekirse silip mutabakatı yeniden yapın.",
|
||||
reconciled: "Mutabakatı yapıldı",
|
||||
cancelInstallments: "Kalan taksitleri iptal et",
|
||||
contactEditHint: "Cari hareketi cari sayfasından düzenlenir.",
|
||||
inKindPaymentLocked: "Aynî borç ödemesi silinemez; ödeme cari sayfasındaki kayda bağlıdır.",
|
||||
readOnly: "İzleyici rolündesiniz; işlem ekleyemezsiniz.",
|
||||
noAccounts: "İşlem girmek için önce bir hesap gerekli.",
|
||||
noAccountsLink: "Hesaplara git",
|
||||
@@ -473,6 +476,185 @@ export const tr = {
|
||||
upload: "Dosya yüklenemedi.",
|
||||
},
|
||||
},
|
||||
contacts: {
|
||||
title: "Cari hesaplar",
|
||||
description:
|
||||
"Kişilerle borç-alacak takibi. Cari hareketleri transferdir; gelir/gider raporlarına girmez.",
|
||||
contact: "Cari",
|
||||
openContact: "Cariyi aç",
|
||||
new: "Yeni cari",
|
||||
createTitle: "Yeni cari",
|
||||
editTitle: "Cariyi düzenle",
|
||||
relations: {
|
||||
family: "Aile / akraba",
|
||||
friend: "Arkadaş",
|
||||
neighbor: "Komşu",
|
||||
work: "İş",
|
||||
other: "Diğer",
|
||||
},
|
||||
kinds: { lend: "Borç verdim", repay: "Borç ödedim", borrow: "Borç aldım", collect: "Tahsilat" },
|
||||
kindHints: {
|
||||
lend: "Hesabınızdan kişiye para verdiniz; size borçlanır.",
|
||||
repay: "Kişiye olan borcunuzu hesabınızdan ödediniz.",
|
||||
borrow: "Kişiden hesabınıza para aldınız; ona borçlanırsınız.",
|
||||
collect: "Kişinin size olan borcunu hesabınıza tahsil ettiniz.",
|
||||
},
|
||||
fields: {
|
||||
name: "Ad soyad",
|
||||
relation: "Yakınlık",
|
||||
phone: "Telefon",
|
||||
note: "Not",
|
||||
openingBalance: "Açılış bakiyesi (TL)",
|
||||
openingHint:
|
||||
"Uygulamadan önceki durum: size borçluysa pozitif, siz borçluysanız başına - koyarak girin.",
|
||||
isPrivate: "Yalnızca ben (bu cariyi ve hareketlerini yalnızca ben görürüm)",
|
||||
dueDate: "Vade tarihi",
|
||||
dueHint: "Kalan bakiyenin ödenmesi beklenen tarih (isteğe bağlı).",
|
||||
reminderDays: "Kaç gün önce uyarılsın",
|
||||
account: "Hesap",
|
||||
fromAccount: "Ödemenin çıktığı hesap",
|
||||
toAccount: "Paranın girdiği hesap",
|
||||
},
|
||||
balance: { owesMe: "Bana borçlu", iOwe: "Ben borçluyum", settled: "Hesap kapalı" },
|
||||
summary: { owesMe: "Bana borçlu", iOwe: "Ben borçluyum", net: "Net" },
|
||||
newEntry: "Hareket ekle",
|
||||
entryTitle: "Cari hareketi",
|
||||
editEntryTitle: "Cari hareketini düzenle",
|
||||
due: {
|
||||
overdue: (d: string) => `Vade geçti · ${d}`,
|
||||
upcoming: (d: string) => `Vade yaklaşıyor · ${d}`,
|
||||
on: (d: string) => `Vade · ${d}`,
|
||||
},
|
||||
dueBanner: (n: number) => `${n} carinin vadesi geçti veya yaklaşıyor.`,
|
||||
dueLink: "Carilere git",
|
||||
statement: "Cari ekstresi",
|
||||
opening: "Açılış bakiyesi",
|
||||
runningBalance: "Bakiye",
|
||||
lastActivity: (d: string) => `Son hareket ${d}`,
|
||||
empty: "Henüz cari yok. Borç verdiğiniz veya aldığınız kişileri ekleyin.",
|
||||
emptyEntries: "Henüz hareket yok.",
|
||||
archive: "Arşivle",
|
||||
unarchive: "Arşivden çıkar",
|
||||
archivedSection: (n: number) => `Arşivlenmiş cariler (${n})`,
|
||||
privateBadge: "Yalnızca ben",
|
||||
readOnly: "İzleyici rolündesiniz; cari hareketi ekleyemezsiniz.",
|
||||
toast: {
|
||||
created: "Cari oluşturuldu.",
|
||||
updated: "Cari güncellendi.",
|
||||
entrySaved: "Cari hareketi kaydedildi.",
|
||||
archived: "Cari arşivlendi.",
|
||||
unarchived: "Cari arşivden çıkarıldı.",
|
||||
},
|
||||
errors: {
|
||||
duplicate: "Bu adla bir cari zaten var.",
|
||||
archived: "Arşivlenmiş cariye hareket eklenemez.",
|
||||
},
|
||||
},
|
||||
inKind: {
|
||||
title: "Aynî borç ve alacaklar",
|
||||
description:
|
||||
"Altın, ziynet veya döviz cinsinden borç/alacak. Kapanış yalnızca “Ödeme gerçekleşti” ile.",
|
||||
new: "Aynî kayıt ekle",
|
||||
createTitle: "Yeni aynî borç/alacak",
|
||||
editTitle: "Aynî kaydı düzenle",
|
||||
directions: { debt: "Ben borçluyum", receivable: "Bana borçlu" },
|
||||
fields: {
|
||||
direction: "Yön",
|
||||
asset: "Varlık",
|
||||
quantity: "Miktar",
|
||||
date: "Borç tarihi",
|
||||
unitPrice: "O tarihteki birim fiyat (TL)",
|
||||
unitPriceHint: "Boş bırakılırsa fiyat tablosundaki o tarihe ait son fiyat kullanılır.",
|
||||
note: "Not",
|
||||
savingsAccount: "Birikim hesabı",
|
||||
savingsNone: "— Birikim planı yok —",
|
||||
monthlyPlan: "Aylık ayrılacak tutar (TL)",
|
||||
planHint:
|
||||
"Borcu kapatmak için her ay bu hesaba para ayırın; borç yalnızca ödeme yapılınca kapanır.",
|
||||
},
|
||||
stats: {
|
||||
remaining: "Kalan",
|
||||
dateValue: (d: string) => `${d} değeri`,
|
||||
currentValue: "Güncel değer",
|
||||
accumulated: "Biriken",
|
||||
remainingNeed: "Kalan ihtiyaç",
|
||||
estimatedClose: "Tahmini kapanış",
|
||||
noPrice: "Güncel fiyat yok",
|
||||
closed: (d: string) => `Kapandı · ${d}`,
|
||||
priceAt: (price: string, date: string) => `Fiyat ${price} (${date})`,
|
||||
currentPrice: (debt: boolean, price: string, date: string) =>
|
||||
`Güncel ${debt ? "satış" : "alış"} fiyatı ${price} · ${date}`,
|
||||
},
|
||||
contribute: "Birikim ekle",
|
||||
contributionTitle: "Birikim ekle",
|
||||
fromAccount: "Kaynak hesap",
|
||||
contributionDescription: (label: string, name: string) => `Birikim: ${label} borcu (${name})`,
|
||||
pay: "Ödeme gerçekleşti",
|
||||
payTitle: "Ödeme gerçekleşti",
|
||||
methods: { asset: "Aynı varlıkla", cash: "TL karşılığıyla" },
|
||||
methodHints: {
|
||||
asset: "Varlığın kendisiyle ödendi (ör. kuyumcudan alınan altın verildi).",
|
||||
cash: "Varlığın TL karşılığı ödendi; hesaptan çıkış işlemi oluşturulur.",
|
||||
},
|
||||
receiveHints: {
|
||||
asset: "Varlığın kendisi teslim alındı.",
|
||||
cash: "Varlığın TL karşılığı hesaba tahsil edildi.",
|
||||
},
|
||||
payFields: {
|
||||
quantity: "Ödenen miktar",
|
||||
quantityHint: "Boş bırakılırsa tutar güncel fiyata bölünerek hesaplanır.",
|
||||
amount: "Tutar (TL)",
|
||||
assetAmount: "Varlığı almak için harcanan (TL, isteğe bağlı)",
|
||||
account: "Hesap",
|
||||
},
|
||||
paymentDescription: (debt: boolean, label: string, name: string) =>
|
||||
debt ? `Aynî borç ödemesi: ${label} (${name})` : `Aynî alacak tahsilatı: ${label} (${name})`,
|
||||
payments: "Ödemeler",
|
||||
paymentLine: (method: string, qty: string, amount: string | null) =>
|
||||
amount ? `${method} · ${qty} · ${amount}` : `${method} · ${qty}`,
|
||||
delete: "Kaydı sil",
|
||||
deleteTitle: "Aynî kayıt silinsin mi?",
|
||||
deleteDescription:
|
||||
"Kayıt listeden kaldırılır. Oluşturulmuş para hareketleri (birikim, ödeme) işlemlerde kalır.",
|
||||
prices: "Fiyat tablosu",
|
||||
toast: {
|
||||
saved: "Aynî kayıt kaydedildi.",
|
||||
contributed: "Birikim eklendi.",
|
||||
paid: "Ödeme kaydedildi.",
|
||||
closed: "Ödeme kaydedildi; kayıt kapandı.",
|
||||
deleted: "Aynî kayıt silindi.",
|
||||
},
|
||||
errors: {
|
||||
noPrice: "Bu tarih için fiyat yok; birim fiyatı girin veya fiyat tablosunu güncelleyin.",
|
||||
noSavings: "Önce kayda bir birikim hesabı seçin.",
|
||||
closed: "Kayıt kapanmış.",
|
||||
quantityBelowPaid: "Miktar ödenmiş miktardan az olamaz.",
|
||||
receivablePlan: "Birikim planı yalnızca borçlar içindir.",
|
||||
noCurrentPrice: "Güncel fiyat yok; ödenen miktarı girin.",
|
||||
sameAccount: "Kaynak hesap birikim hesabıyla aynı olamaz.",
|
||||
},
|
||||
validation: {
|
||||
quantity: "Geçerli bir miktar girin (en fazla 3 ondalık)",
|
||||
price: "Alış veya satış fiyatı girin",
|
||||
amount: "Tutar girin",
|
||||
account: "Hesap seçin",
|
||||
},
|
||||
},
|
||||
prices: {
|
||||
title: "Fiyat tablosu",
|
||||
description:
|
||||
"Altın ve döviz fiyatları (elle). Varlıklar ve aynî alacaklar alış, aynî borçlar satış fiyatıyla değerlenir.",
|
||||
add: "Fiyat gir",
|
||||
asset: "Varlık",
|
||||
date: "Tarih",
|
||||
buy: "Alış (TL)",
|
||||
sell: "Satış (TL)",
|
||||
sellHint: "Boş bırakılırsa alış fiyatı kullanılır (tek fiyat).",
|
||||
noPrice: "Fiyat yok",
|
||||
history: "Son fiyatlar",
|
||||
perUnit: (unit: string) => `/ ${unit}`,
|
||||
saved: "Fiyat kaydedildi.",
|
||||
},
|
||||
card: {
|
||||
title: "Kart özeti",
|
||||
debt: "Toplam borç",
|
||||
@@ -766,6 +948,7 @@ export const tr = {
|
||||
validation: {
|
||||
nameMin: "Ad en az 2 karakter olmalı",
|
||||
nameMax: "Ad en fazla 80 karakter olabilir",
|
||||
phone: "Geçerli bir telefon numarası girin",
|
||||
usernameFormat: "Yalnızca küçük harf, rakam, nokta ve alt çizgi; 3–30 karakter",
|
||||
email: "Geçerli bir e-posta adresi girin",
|
||||
passwordMin: "Şifre en az 8 karakter olmalı",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||
import { ASSET_KEYS } from "@/shared/assets";
|
||||
|
||||
/**
|
||||
* Fiyat tablosu (Bölüm 6.6): varlık × tarih × alış/satış (birim başına kuruş). İlk aşamada elle
|
||||
* giriş; otomatik kaynaklar Faz 17'de `source` ile eklenir. Gün başına tek kayıt (yeniden girişte
|
||||
* güncellenir).
|
||||
*/
|
||||
const assetPriceSchema = new Schema(
|
||||
{
|
||||
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||
assetKey: { type: String, enum: ASSET_KEYS, required: true },
|
||||
/** "YYYY-MM-DD" (İstanbul). */
|
||||
date: { type: String, required: true },
|
||||
buyMinor: { type: Number, required: true, min: 0 },
|
||||
sellMinor: { type: Number, required: true, min: 0 },
|
||||
source: { type: String, enum: ["manual"], default: "manual" },
|
||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||
},
|
||||
{ timestamps: true, collection: "assetPrices" },
|
||||
);
|
||||
|
||||
assetPriceSchema.index({ householdId: 1, assetKey: 1, date: -1 }, { unique: true });
|
||||
|
||||
export type AssetPrice = InferSchemaType<typeof assetPriceSchema>;
|
||||
|
||||
export const AssetPriceModel: Model<AssetPrice> =
|
||||
(models.AssetPrice as Model<AssetPrice> | undefined) ??
|
||||
model<AssetPrice>("AssetPrice", assetPriceSchema);
|
||||
@@ -28,6 +28,11 @@ export const AUDIT_ACTIONS = [
|
||||
"transaction.delete",
|
||||
"transaction.restore",
|
||||
"recurring.delete",
|
||||
"contact.archive",
|
||||
"contact.unarchive",
|
||||
"inkind.payment",
|
||||
"inkind.close",
|
||||
"inkind.delete",
|
||||
] as const;
|
||||
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||
import { CONTACT_RELATIONS } from "@/shared/contacts";
|
||||
|
||||
/**
|
||||
* Cari kişi (Bölüm 6.4). Hareketler `transactions` içinde transfer olarak tutulur (`contactId`);
|
||||
* bakiye: açılış + çıkışlar − girişler (+ bana borçlu, − ben borçluyum).
|
||||
* "Yalnızca ben" işaretli cari ve hareketleri yalnızca sahibine görünür.
|
||||
*/
|
||||
const contactSchema = new Schema(
|
||||
{
|
||||
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||
name: { type: String, required: true, trim: true, maxlength: 80 },
|
||||
relation: { type: String, enum: CONTACT_RELATIONS, default: "other" },
|
||||
phone: { type: String, default: "", maxlength: 30 },
|
||||
note: { type: String, default: "", maxlength: 500 },
|
||||
/** Uygulamaya başlamadan önceki bakiye (+ bana borçlu, − ben borçluyum). */
|
||||
openingBalanceMinor: { type: Number, default: 0 },
|
||||
openingDate: { type: Date, required: true },
|
||||
/** Kalan bakiyenin vadesi ("YYYY-MM-DD") ve kaç gün önce uyarılacağı. */
|
||||
dueDate: { type: String, default: null },
|
||||
reminderDays: { type: Number, default: 3, min: 0, max: 60 },
|
||||
isPrivate: { type: Boolean, default: false },
|
||||
ownerUserId: { type: Schema.Types.ObjectId, default: null },
|
||||
archivedAt: { type: Date, default: null },
|
||||
seedRef: { type: String, default: null },
|
||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||
updatedBy: { type: Schema.Types.ObjectId, required: true },
|
||||
},
|
||||
{ timestamps: true, collection: "contacts" },
|
||||
);
|
||||
|
||||
contactSchema.index({ householdId: 1, archivedAt: 1, name: 1 });
|
||||
contactSchema.index(
|
||||
{ householdId: 1, seedRef: 1 },
|
||||
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||
);
|
||||
|
||||
export type Contact = InferSchemaType<typeof contactSchema>;
|
||||
|
||||
export const ContactModel: Model<Contact> =
|
||||
(models.Contact as Model<Contact> | undefined) ?? model<Contact>("Contact", contactSchema);
|
||||
@@ -0,0 +1,61 @@
|
||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||
import { ASSET_KEYS } from "@/shared/assets";
|
||||
|
||||
/**
|
||||
* Aynî borç/alacak (Bölüm 6.5): bir cariyle altın, ziynet veya döviz cinsinden borç.
|
||||
* Borç tarihi fiyatı ve TL karşılığı not olarak saklanır; güncel değer fiyat tablosundan.
|
||||
* Kapanış yalnızca "Ödeme gerçekleşti" kayıtlarıyla (kısmi ödeme miktarı düşürür).
|
||||
* Birikim planı: katkılar seçilen birikim hesabına transferdir (`transactions.inKindDebtId`).
|
||||
*/
|
||||
const paymentSchema = new Schema(
|
||||
{
|
||||
date: { type: String, required: true },
|
||||
/** asset: aynı varlıkla · cash: TL karşılığıyla. */
|
||||
method: { type: String, enum: ["asset", "cash"], required: true },
|
||||
quantity: { type: Number, required: true, min: 1 },
|
||||
/** Ödemede harcanan / tahsil edilen TL (aynı varlıkla ödemede varlığı almak için harcanan). */
|
||||
amountMinor: { type: Number, default: 0, min: 0 },
|
||||
accountId: { type: Schema.Types.ObjectId, default: null },
|
||||
transactionId: { type: Schema.Types.ObjectId, default: null },
|
||||
note: { type: String, default: "", maxlength: 300 },
|
||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||
},
|
||||
{ _id: true, timestamps: { createdAt: true, updatedAt: false } },
|
||||
);
|
||||
|
||||
const inKindDebtSchema = new Schema(
|
||||
{
|
||||
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||
contactId: { type: Schema.Types.ObjectId, required: true },
|
||||
/** debt: ben borçluyum · receivable: bana borçlu. */
|
||||
direction: { type: String, enum: ["debt", "receivable"], required: true },
|
||||
assetKey: { type: String, enum: ASSET_KEYS, required: true },
|
||||
/** Binde bir (5 gr → 5000). */
|
||||
quantity: { type: Number, required: true, min: 1 },
|
||||
date: { type: String, required: true },
|
||||
unitPriceMinor: { type: Number, default: null },
|
||||
valueMinor: { type: Number, default: null },
|
||||
note: { type: String, default: "", maxlength: 500 },
|
||||
savingsAccountId: { type: Schema.Types.ObjectId, default: null },
|
||||
monthlyPlanMinor: { type: Number, default: null },
|
||||
payments: { type: [paymentSchema], default: [] },
|
||||
closedAt: { type: String, default: null },
|
||||
deletedAt: { type: Date, default: null },
|
||||
seedRef: { type: String, default: null },
|
||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||
updatedBy: { type: Schema.Types.ObjectId, required: true },
|
||||
},
|
||||
{ timestamps: true, collection: "inKindDebts" },
|
||||
);
|
||||
|
||||
inKindDebtSchema.index({ householdId: 1, contactId: 1, deletedAt: 1 });
|
||||
inKindDebtSchema.index(
|
||||
{ householdId: 1, seedRef: 1 },
|
||||
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||
);
|
||||
|
||||
export type InKindDebt = InferSchemaType<typeof inKindDebtSchema>;
|
||||
|
||||
export const InKindDebtModel: Model<InKindDebt> =
|
||||
(models.InKindDebt as Model<InKindDebt> | undefined) ??
|
||||
model<InKindDebt>("InKindDebt", inKindDebtSchema);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||
import { CONTACT_ENTRY_KINDS } from "@/shared/contacts";
|
||||
import { TRANSACTION_STATUSES, TRANSACTION_TYPES } from "@/shared/transactions";
|
||||
|
||||
/**
|
||||
@@ -21,7 +22,8 @@ const transactionSchema = new Schema(
|
||||
status: { type: String, enum: TRANSACTION_STATUSES, default: "posted" },
|
||||
/** İstanbul gününün başlangıç anı (UTC). */
|
||||
date: { type: Date, required: true },
|
||||
accountId: { type: Schema.Types.ObjectId, required: true },
|
||||
/** Kaynak hesap. Cariden girişte (borç alma, tahsilat) null; hedef `toAccountId`. */
|
||||
accountId: { type: Schema.Types.ObjectId, default: null },
|
||||
toAccountId: { type: Schema.Types.ObjectId, default: null },
|
||||
amountMinor: { type: Number, required: true, min: 1 },
|
||||
/** Bölünmemiş gelir/giderde kategori; bölünmüşte null ve kalemler `splits`te. */
|
||||
@@ -53,7 +55,15 @@ const transactionSchema = new Schema(
|
||||
default: null,
|
||||
},
|
||||
statementId: { type: Schema.Types.ObjectId, default: null },
|
||||
/** Cari transferi: karşı taraf kişi (hesabın biri boş). */
|
||||
contactId: { type: Schema.Types.ObjectId, default: null },
|
||||
contactKind: { type: String, enum: [...CONTACT_ENTRY_KINDS, null], default: null },
|
||||
/**
|
||||
* Aynî borca bağlı para hareketi: birikim katkısı (hesap → birikim hesabı) veya ödeme.
|
||||
* `contactId` görünürlük için doludur ama `contactKind` boştur: cari TL bakiyesine girmez.
|
||||
*/
|
||||
inKindDebtId: { type: Schema.Types.ObjectId, default: null },
|
||||
inKindRole: { type: String, enum: ["contribution", "payment", null], default: null },
|
||||
/**
|
||||
* Mutabakatı yapılmış hesaplar (Bölüm 6.1). Transfer iki hesaba dokunduğu için hesap bazında
|
||||
* tutulur. Tutar, tarih, tür veya hesap değişirse işaret kalkar.
|
||||
@@ -76,6 +86,7 @@ transactionSchema.index({ householdId: 1, accountId: 1, date: -1 });
|
||||
transactionSchema.index({ householdId: 1, toAccountId: 1, date: -1 });
|
||||
transactionSchema.index({ householdId: 1, effectiveCategoryIds: 1, date: -1 });
|
||||
transactionSchema.index({ householdId: 1, tags: 1 });
|
||||
transactionSchema.index({ householdId: 1, contactId: 1, date: 1 }, { sparse: true });
|
||||
transactionSchema.index({ "installment.planId": 1 }, { sparse: true });
|
||||
transactionSchema.index(
|
||||
{ recurringRuleId: 1, occurrenceIndex: 1 },
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatAssetQuantity,
|
||||
inKindProgress,
|
||||
quantityForAmount,
|
||||
valuationPrice,
|
||||
valueOf,
|
||||
} from "./assets";
|
||||
|
||||
describe("varlık hesapları", () => {
|
||||
it("değer ve miktar karşılığı (binde bir miktar, kuruş fiyat)", () => {
|
||||
// 5 gr × 3.250,50 TL = 16.252,50 TL
|
||||
expect(valueOf(5_000, 3_250_50)).toBe(16_252_50);
|
||||
expect(valueOf(1_500, 100_00)).toBe(150_00);
|
||||
expect(quantityForAmount(16_252_50, 3_250_50)).toBe(5_000);
|
||||
expect(quantityForAmount(100, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it("biçim", () => {
|
||||
expect(formatAssetQuantity("gram_gold", 5_000)).toBe("5 gr Gram altın (24 ayar)");
|
||||
expect(formatAssetQuantity("quarter_gold", 2_000)).toBe("2 adet Çeyrek altın");
|
||||
expect(formatAssetQuantity("usd", 1_500_000)).toBe("1.500 USD");
|
||||
expect(formatAssetQuantity("bracelet_22k", 12_345)).toBe("12,345 gr 22 ayar bilezik");
|
||||
});
|
||||
|
||||
it("değerleme fiyatı: borç satış, alacak alış", () => {
|
||||
const p = { buyMinor: 3_200_00, sellMinor: 3_300_00 };
|
||||
expect(valuationPrice("debt", p)).toBe(3_300_00);
|
||||
expect(valuationPrice("receivable", p)).toBe(3_200_00);
|
||||
expect(valuationPrice("debt", null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("aynî borç ilerlemesi", () => {
|
||||
const base = {
|
||||
quantity: 5_000,
|
||||
paidQuantity: 0,
|
||||
unitPriceMinor: 3_000_00,
|
||||
contributedMinor: 6_000_00,
|
||||
spentMinor: 0,
|
||||
monthlyPlanMinor: 2_000_00,
|
||||
today: "2026-11-05",
|
||||
};
|
||||
|
||||
it("kalan ihtiyaç ve tahmini kapanış ayı", () => {
|
||||
// 5 gr × 3.000 = 15.000; biriken 6.000 → 9.000 kaldı; ayda 2.000 → 5 ay → 2027-04
|
||||
expect(inKindProgress(base)).toEqual({
|
||||
remainingQuantity: 5_000,
|
||||
currentValueMinor: 15_000_00,
|
||||
accumulatedMinor: 6_000_00,
|
||||
remainingNeedMinor: 9_000_00,
|
||||
estimatedCloseMonth: "2027-04",
|
||||
});
|
||||
});
|
||||
|
||||
it("kısmi ödeme, birikim yeterli, fiyat yok, plan yok", () => {
|
||||
expect(inKindProgress({ ...base, paidQuantity: 3_000 }).remainingNeedMinor).toBe(0);
|
||||
expect(inKindProgress({ ...base, paidQuantity: 3_000 }).estimatedCloseMonth).toBe("2026-11");
|
||||
expect(inKindProgress({ ...base, unitPriceMinor: null }).remainingNeedMinor).toBeNull();
|
||||
expect(inKindProgress({ ...base, monthlyPlanMinor: null }).estimatedCloseMonth).toBeNull();
|
||||
expect(inKindProgress({ ...base, paidQuantity: 5_000 }).remainingQuantity).toBe(0);
|
||||
expect(inKindProgress({ ...base, spentMinor: 9_000_00 }).accumulatedMinor).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { QUANTITY_SCALE } from "./quantity";
|
||||
import { addMonths } from "./recurrence";
|
||||
|
||||
/**
|
||||
* Varlık türleri (Bölüm 6.5–6.6): altın, ziynet, gümüş ve döviz. Miktarlar binde bir tamsayı
|
||||
* (5 gr → 5000, 2 adet çeyrek → 2000). Fiyatlar birim başına kuruş.
|
||||
*
|
||||
* Fiyat terimleri kuyumcu/döviz bürosu tarafından: "alış" = kurumun sizden aldığı (düşük),
|
||||
* "satış" = kurumun size sattığı (yüksek). Faz 0 kararı: varlıklar ve aynî alacaklar ALIŞ,
|
||||
* aynî borçlar SATIŞ fiyatıyla değerlenir.
|
||||
*/
|
||||
|
||||
export const ASSET_KEYS = [
|
||||
"gram_gold",
|
||||
"quarter_gold",
|
||||
"half_gold",
|
||||
"full_gold",
|
||||
"republic_gold",
|
||||
"bracelet_22k",
|
||||
"silver_gram",
|
||||
"usd",
|
||||
"eur",
|
||||
"gbp",
|
||||
] as const;
|
||||
export type AssetKey = (typeof ASSET_KEYS)[number];
|
||||
|
||||
export type AssetUnit = "gr" | "adet" | "birim";
|
||||
|
||||
export const ASSETS: Record<
|
||||
AssetKey,
|
||||
{ label: string; unit: AssetUnit; group: "gold" | "silver" | "fx" }
|
||||
> = {
|
||||
gram_gold: { label: "Gram altın (24 ayar)", unit: "gr", group: "gold" },
|
||||
quarter_gold: { label: "Çeyrek altın", unit: "adet", group: "gold" },
|
||||
half_gold: { label: "Yarım altın", unit: "adet", group: "gold" },
|
||||
full_gold: { label: "Tam altın", unit: "adet", group: "gold" },
|
||||
republic_gold: { label: "Cumhuriyet (Ata) altını", unit: "adet", group: "gold" },
|
||||
bracelet_22k: { label: "22 ayar bilezik", unit: "gr", group: "gold" },
|
||||
silver_gram: { label: "Gümüş (gram)", unit: "gr", group: "silver" },
|
||||
usd: { label: "ABD doları", unit: "birim", group: "fx" },
|
||||
eur: { label: "Euro", unit: "birim", group: "fx" },
|
||||
gbp: { label: "İngiliz sterlini", unit: "birim", group: "fx" },
|
||||
};
|
||||
|
||||
const FX_SYMBOL: Partial<Record<AssetKey, string>> = { usd: "USD", eur: "EUR", gbp: "GBP" };
|
||||
|
||||
/** "5 gr Gram altın (24 ayar)", "2 adet Çeyrek altın", "1.500 USD". */
|
||||
export function formatAssetQuantity(key: AssetKey, quantity: number): string {
|
||||
const n = (quantity / QUANTITY_SCALE).toLocaleString("tr-TR", { maximumFractionDigits: 3 });
|
||||
const fx = FX_SYMBOL[key];
|
||||
if (fx) return `${n} ${fx}`;
|
||||
const a = ASSETS[key];
|
||||
return `${n} ${a.unit} ${a.label}`;
|
||||
}
|
||||
|
||||
/** Miktar × birim fiyat (kuruş, yuvarlanır). */
|
||||
export function valueOf(quantity: number, unitPriceMinor: number): number {
|
||||
return Math.round((quantity * unitPriceMinor) / QUANTITY_SCALE);
|
||||
}
|
||||
|
||||
/** TL tutarın karşılığı olan miktar (binde bir, yuvarlanır). */
|
||||
export function quantityForAmount(amountMinor: number, unitPriceMinor: number): number {
|
||||
if (unitPriceMinor <= 0) return 0;
|
||||
return Math.round((amountMinor * QUANTITY_SCALE) / unitPriceMinor);
|
||||
}
|
||||
|
||||
export type InKindDirection = "debt" | "receivable";
|
||||
|
||||
/** Değerlemede kullanılacak fiyat: borç → satış, alacak → alış. */
|
||||
export function valuationPrice(
|
||||
direction: InKindDirection,
|
||||
price: { buyMinor: number; sellMinor: number } | null,
|
||||
): number | null {
|
||||
if (!price) return null;
|
||||
return direction === "debt" ? price.sellMinor : price.buyMinor;
|
||||
}
|
||||
|
||||
export type InKindProgress = {
|
||||
remainingQuantity: number;
|
||||
/** Kalan miktarın güncel TL karşılığı (fiyat yoksa null). */
|
||||
currentValueMinor: number | null;
|
||||
/** Birikim planı: biriken − ödemelerde kullanılan. */
|
||||
accumulatedMinor: number;
|
||||
remainingNeedMinor: number | null;
|
||||
/** Aylık birikimle kalan ihtiyaç kaç ayda kapanır; "YYYY-MM" (plan yoksa null). */
|
||||
estimatedCloseMonth: string | null;
|
||||
};
|
||||
|
||||
export function inKindProgress(input: {
|
||||
quantity: number;
|
||||
paidQuantity: number;
|
||||
unitPriceMinor: number | null;
|
||||
contributedMinor: number;
|
||||
spentMinor: number;
|
||||
monthlyPlanMinor: number | null;
|
||||
today: string;
|
||||
}): InKindProgress {
|
||||
const remainingQuantity = Math.max(0, input.quantity - input.paidQuantity);
|
||||
const currentValueMinor =
|
||||
input.unitPriceMinor === null ? null : valueOf(remainingQuantity, input.unitPriceMinor);
|
||||
const accumulatedMinor = Math.max(0, input.contributedMinor - input.spentMinor);
|
||||
const remainingNeedMinor =
|
||||
currentValueMinor === null ? null : Math.max(0, currentValueMinor - accumulatedMinor);
|
||||
let estimatedCloseMonth: string | null = null;
|
||||
if (remainingQuantity === 0) estimatedCloseMonth = null;
|
||||
else if (remainingNeedMinor === 0) estimatedCloseMonth = input.today.slice(0, 7);
|
||||
else if (remainingNeedMinor !== null && input.monthlyPlanMinor && input.monthlyPlanMinor > 0) {
|
||||
const months = Math.ceil(remainingNeedMinor / input.monthlyPlanMinor);
|
||||
estimatedCloseMonth = addMonths(`${input.today.slice(0, 7)}-01`, months).slice(0, 7);
|
||||
}
|
||||
return {
|
||||
remainingQuantity,
|
||||
currentValueMinor,
|
||||
accumulatedMinor,
|
||||
remainingNeedMinor,
|
||||
estimatedCloseMonth,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { contactDelta, dueState, isOutgoing } from "./contacts";
|
||||
import { addDays } from "./recurrence";
|
||||
|
||||
describe("cari", () => {
|
||||
it("yön ve bakiye etkisi", () => {
|
||||
expect(isOutgoing("lend")).toBe(true);
|
||||
expect(isOutgoing("repay")).toBe(true);
|
||||
expect(isOutgoing("borrow")).toBe(false);
|
||||
expect(contactDelta("lend", 100)).toBe(100);
|
||||
expect(contactDelta("repay", 100)).toBe(100);
|
||||
expect(contactDelta("borrow", 100)).toBe(-100);
|
||||
expect(contactDelta("collect", 100)).toBe(-100);
|
||||
});
|
||||
|
||||
it("vade durumu", () => {
|
||||
const today = "2026-11-05";
|
||||
expect(dueState(100, null, 3, today, addDays)).toBe("none");
|
||||
expect(dueState(0, "2026-11-01", 3, today, addDays)).toBe("none");
|
||||
expect(dueState(-100, "2026-11-04", 3, today, addDays)).toBe("overdue");
|
||||
expect(dueState(100, "2026-11-08", 3, today, addDays)).toBe("upcoming");
|
||||
expect(dueState(100, "2026-11-09", 3, today, addDays)).toBe("none");
|
||||
expect(dueState(100, "2026-11-05", 0, today, addDays)).toBe("upcoming");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/** Cari hesaplar (Bölüm 6.4). */
|
||||
|
||||
export const CONTACT_RELATIONS = ["family", "friend", "neighbor", "work", "other"] as const;
|
||||
export type ContactRelation = (typeof CONTACT_RELATIONS)[number];
|
||||
|
||||
/**
|
||||
* Cari hareket türleri. Hesaptan cariye (çıkış): borç verdim, borcumu ödedim.
|
||||
* Cariden hesaba (giriş): borç aldım, tahsilat yaptım. Hepsi transferdir; gelir/gider değildir.
|
||||
*/
|
||||
export const CONTACT_ENTRY_KINDS = ["lend", "repay", "borrow", "collect"] as const;
|
||||
export type ContactEntryKind = (typeof CONTACT_ENTRY_KINDS)[number];
|
||||
|
||||
/** Para hesaptan cariye mi gidiyor? */
|
||||
export const isOutgoing = (kind: ContactEntryKind) => kind === "lend" || kind === "repay";
|
||||
|
||||
/**
|
||||
* Cari bakiyesi (kuruş): + "Bana borçlu", − "Ben borçluyum".
|
||||
* Çıkışlar (verdim/ödedim) bakiyeyi artırır, girişler (aldım/tahsil ettim) azaltır.
|
||||
*/
|
||||
export function contactDelta(kind: ContactEntryKind, amountMinor: number): number {
|
||||
return isOutgoing(kind) ? amountMinor : -amountMinor;
|
||||
}
|
||||
|
||||
export type DueState = "none" | "upcoming" | "overdue";
|
||||
|
||||
/** Vade uyarısı: bakiye kapanmamışsa, vade geçtiyse "overdue", hatırlatma penceresindeyse "upcoming". */
|
||||
export function dueState(
|
||||
balanceMinor: number,
|
||||
dueDate: string | null,
|
||||
reminderDays: number,
|
||||
today: string,
|
||||
addDays: (ymd: string, n: number) => string,
|
||||
): DueState {
|
||||
if (!dueDate || balanceMinor === 0) return "none";
|
||||
if (dueDate < today) return "overdue";
|
||||
return addDays(today, reminderDays) >= dueDate ? "upcoming" : "none";
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { z } from "zod";
|
||||
import { tr } from "../../i18n/tr";
|
||||
import { CONTACT_ENTRY_KINDS, CONTACT_RELATIONS } from "../contacts";
|
||||
import { parseMoney } from "../money";
|
||||
import { moneyInputSchema } from "./account";
|
||||
|
||||
const v = tr.validation;
|
||||
const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date);
|
||||
const optionalYmd = z.preprocess((x) => (x === "" || x === undefined ? null : x), ymd.nullable());
|
||||
const reminderDays = z.coerce.number().int().min(0).max(60).default(3);
|
||||
|
||||
/** Cari kişi formu (web + mobil). */
|
||||
export const contactFormSchema = z.object({
|
||||
name: z.string().trim().min(1, tr.common.required).max(80, v.nameMax),
|
||||
relation: z.enum(CONTACT_RELATIONS).default("other"),
|
||||
phone: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(30)
|
||||
.regex(/^[0-9+()\s-]*$/, v.phone)
|
||||
.default(""),
|
||||
note: z.string().trim().max(500).default(""),
|
||||
/** + bana borçlu, − ben borçluyum. */
|
||||
openingBalance: z.string().default("").pipe(moneyInputSchema),
|
||||
openingDate: ymd,
|
||||
isPrivate: z.boolean().default(false),
|
||||
dueDate: optionalYmd,
|
||||
reminderDays,
|
||||
});
|
||||
export type ContactFormInput = z.input<typeof contactFormSchema>;
|
||||
export type ContactForm = z.output<typeof contactFormSchema>;
|
||||
|
||||
/** Cari hareketi: hesaptan cariye veya cariden hesaba transfer. */
|
||||
export const contactEntrySchema = z.object({
|
||||
kind: z.enum(CONTACT_ENTRY_KINDS),
|
||||
accountId: z.string().regex(/^[a-f0-9]{24}$/i, tr.transactions.validation.selectAccount),
|
||||
amount: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((s, ctx) => {
|
||||
const minor = parseMoney(s);
|
||||
if (minor === null || minor <= 0) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: minor === null ? v.money : tr.transactions.validation.amountPositive,
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return minor;
|
||||
}),
|
||||
date: ymd,
|
||||
description: z.string().trim().max(500).default(""),
|
||||
/** Verilirse carinin vadesi güncellenir (boş string → vade kaldırılır). */
|
||||
dueDate: optionalYmd.optional(),
|
||||
reminderDays: reminderDays.optional(),
|
||||
});
|
||||
export type ContactEntryInput = z.input<typeof contactEntrySchema>;
|
||||
export type ContactEntry = z.output<typeof contactEntrySchema>;
|
||||
@@ -0,0 +1,116 @@
|
||||
import { z } from "zod";
|
||||
import { tr } from "../../i18n/tr";
|
||||
import { ASSET_KEYS } from "../assets";
|
||||
import { parseMoney } from "../money";
|
||||
import { parseQuantity } from "../quantity";
|
||||
|
||||
const v = tr.validation;
|
||||
const k = tr.inKind.validation;
|
||||
const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date);
|
||||
const objectId = z.string().regex(/^[a-f0-9]{24}$/i, tr.transactions.validation.selectAccount);
|
||||
const emptyToNull = (x: unknown) => (x === "" || x === undefined ? null : x);
|
||||
|
||||
/** Boş → null; dolu → pozitif kuruş. */
|
||||
const optionalMoney = z.preprocess(
|
||||
emptyToNull,
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((s, ctx) => {
|
||||
const minor = parseMoney(s);
|
||||
if (minor === null || minor < 0) {
|
||||
ctx.addIssue({ code: "custom", message: v.money });
|
||||
return z.NEVER;
|
||||
}
|
||||
return minor;
|
||||
})
|
||||
.nullable(),
|
||||
);
|
||||
|
||||
const quantity = z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((s, ctx) => {
|
||||
const q = parseQuantity(s);
|
||||
if (q === null || q <= 0) {
|
||||
ctx.addIssue({ code: "custom", message: k.quantity });
|
||||
return z.NEVER;
|
||||
}
|
||||
return q;
|
||||
});
|
||||
|
||||
/** Fiyat girişi: satış boşsa alışla aynı (tek fiyat girilirse ikisine de yazılır — Faz 0 kararı). */
|
||||
export const assetPriceFormSchema = z
|
||||
.object({
|
||||
assetKey: z.enum(ASSET_KEYS),
|
||||
date: ymd,
|
||||
buy: optionalMoney,
|
||||
sell: optionalMoney,
|
||||
})
|
||||
.superRefine((x, ctx) => {
|
||||
if (x.buy === null && x.sell === null)
|
||||
ctx.addIssue({ code: "custom", path: ["buy"], message: k.price });
|
||||
})
|
||||
.transform((x) => ({
|
||||
assetKey: x.assetKey,
|
||||
date: x.date,
|
||||
buyMinor: (x.buy ?? x.sell)!,
|
||||
sellMinor: (x.sell ?? x.buy)!,
|
||||
}));
|
||||
export type AssetPriceForm = z.output<typeof assetPriceFormSchema>;
|
||||
|
||||
/** Aynî borç/alacak kaydı. Birim fiyat boşsa fiyat tablosundan (o tarihteki son fiyat) alınır. */
|
||||
export const inKindFormSchema = z.object({
|
||||
direction: z.enum(["debt", "receivable"]),
|
||||
assetKey: z.enum(ASSET_KEYS),
|
||||
quantity,
|
||||
date: ymd,
|
||||
unitPrice: optionalMoney,
|
||||
note: z.string().trim().max(500).default(""),
|
||||
savingsAccountId: z.preprocess(emptyToNull, objectId.nullable()),
|
||||
monthlyPlan: optionalMoney,
|
||||
});
|
||||
export type InKindForm = z.output<typeof inKindFormSchema>;
|
||||
|
||||
/** Birikim katkısı: hesaptan borcun birikim hesabına transfer. */
|
||||
export const contributionSchema = z.object({
|
||||
fromAccountId: objectId,
|
||||
amount: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((s, ctx) => {
|
||||
const minor = parseMoney(s);
|
||||
if (minor === null || minor <= 0) {
|
||||
ctx.addIssue({ code: "custom", message: tr.transactions.validation.amountPositive });
|
||||
return z.NEVER;
|
||||
}
|
||||
return minor;
|
||||
}),
|
||||
date: ymd,
|
||||
});
|
||||
export type ContributionForm = z.output<typeof contributionSchema>;
|
||||
|
||||
/**
|
||||
* "Ödeme gerçekleşti". Aynı varlıkla: miktar zorunlu, TL (varlığı almak için harcanan) isteğe
|
||||
* bağlı. TL karşılığıyla: tutar ve hesap zorunlu; miktar boşsa güncel fiyattan hesaplanır.
|
||||
*/
|
||||
export const paymentFormSchema = z
|
||||
.object({
|
||||
method: z.enum(["asset", "cash"]),
|
||||
date: ymd,
|
||||
quantity: z.preprocess(emptyToNull, quantity.nullable()),
|
||||
amount: optionalMoney,
|
||||
accountId: z.preprocess(emptyToNull, objectId.nullable()),
|
||||
note: z.string().trim().max(300).default(""),
|
||||
})
|
||||
.superRefine((x, ctx) => {
|
||||
if (x.method === "asset" && x.quantity === null)
|
||||
ctx.addIssue({ code: "custom", path: ["quantity"], message: k.quantity });
|
||||
if (x.method === "cash") {
|
||||
if (!x.amount) ctx.addIssue({ code: "custom", path: ["amount"], message: k.amount });
|
||||
if (!x.accountId) ctx.addIssue({ code: "custom", path: ["accountId"], message: k.account });
|
||||
}
|
||||
if (x.amount && !x.accountId)
|
||||
ctx.addIssue({ code: "custom", path: ["accountId"], message: k.account });
|
||||
});
|
||||
export type PaymentForm = z.output<typeof paymentFormSchema>;
|
||||
+12
-10
@@ -13,7 +13,8 @@ export type EffectInput = {
|
||||
type: TransactionType;
|
||||
status: TransactionStatus;
|
||||
amountMinor: number;
|
||||
accountId: string;
|
||||
/** Cariden girişte (borç alma, tahsilat) boştur. */
|
||||
accountId: string | null;
|
||||
toAccountId?: string | null;
|
||||
deleted?: boolean;
|
||||
};
|
||||
@@ -27,16 +28,17 @@ export function transactionEffects(t: EffectInput): { accountId: string; deltaMi
|
||||
if (t.status !== "posted" || t.deleted) return [];
|
||||
switch (t.type) {
|
||||
case "income":
|
||||
return [{ accountId: t.accountId, deltaMinor: t.amountMinor }];
|
||||
return t.accountId ? [{ accountId: t.accountId, deltaMinor: t.amountMinor }] : [];
|
||||
case "expense":
|
||||
return [{ accountId: t.accountId, deltaMinor: -t.amountMinor }];
|
||||
case "transfer":
|
||||
return t.toAccountId
|
||||
? [
|
||||
{ accountId: t.accountId, deltaMinor: -t.amountMinor },
|
||||
{ accountId: t.toAccountId, deltaMinor: t.amountMinor },
|
||||
]
|
||||
: [];
|
||||
return t.accountId ? [{ accountId: t.accountId, deltaMinor: -t.amountMinor }] : [];
|
||||
case "transfer": {
|
||||
// Hesap ↔ hesap; cari transferinde bir taraf boştur (cariye çıkış: toAccountId yok,
|
||||
// cariden giriş: accountId yok).
|
||||
const out: { accountId: string; deltaMinor: number }[] = [];
|
||||
if (t.accountId) out.push({ accountId: t.accountId, deltaMinor: -t.amountMinor });
|
||||
if (t.toAccountId) out.push({ accountId: t.toAccountId, deltaMinor: t.amountMinor });
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user